@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.
- package/LICENSE +13 -0
- package/README.md +80 -0
- package/docs/naming.md +40 -0
- package/package.json +24 -0
- package/src/context-hardening.test.ts +93 -0
- package/src/contrib/crypto.test.ts +60 -0
- package/src/contrib/crypto.ts +74 -0
- package/src/contrib/custom-list-query.ts +51 -0
- package/src/contrib/debug.ts +42 -0
- package/src/contrib/debugui.test.ts +10 -0
- package/src/contrib/debugui.ts +27 -0
- package/src/contrib/http-guard.test.ts +116 -0
- package/src/contrib/http.test.ts +230 -0
- package/src/contrib/http.ts +283 -0
- package/src/contrib/ip-location.ts +82 -0
- package/src/contrib/legacy-graph-acceptance.test.ts +67 -0
- package/src/contrib/rate-store-conformance.ts +68 -0
- package/src/contrib/rate-store.test.ts +30 -0
- package/src/contrib/rate-window.ts +195 -0
- package/src/contrib/rebuilt-functions.test.ts +60 -0
- package/src/contrib/roster.test.ts +27 -0
- package/src/contrib/roster.ts +38 -0
- package/src/decision-cache.test.ts +122 -0
- package/src/decision-cache.ts +104 -0
- package/src/decision-runtime.test.ts +154 -0
- package/src/engine-cache-semantics.test.ts +87 -0
- package/src/engine.ts +474 -0
- package/src/exec-context.test.ts +52 -0
- package/src/exec-context.ts +32 -0
- package/src/execution-spec.test.ts +173 -0
- package/src/index.ts +43 -0
- package/src/limiter.ts +70 -0
- package/src/reference.ts +34 -0
- package/src/register.test.ts +60 -0
- package/src/register.ts +515 -0
- package/src/roster.test.ts +96 -0
- package/src/roster.ts +129 -0
- package/src/sanitize.test.ts +65 -0
- package/src/udf-pack.test.ts +107 -0
- package/src/udf-trace.test.ts +137 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2024 GoRules.io
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
|
|
4
|
+
files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy,
|
|
5
|
+
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
|
6
|
+
is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
11
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
12
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
13
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @republicroad/zen-udf
|
|
2
|
+
|
|
3
|
+
zen-engine 的 customNode UDF 运行时:**多函数实例执行规范 + UDF 注册表 + 决策缓存 + 多租户数据面端口**。
|
|
4
|
+
|
|
5
|
+
基于 [@gorules/zen-engine](https://www.npmjs.com/package/@gorules/zen-engine)(2.0.2),补齐其在服务端执行自定义节点的完整能力。
|
|
6
|
+
|
|
7
|
+
## 定位
|
|
8
|
+
|
|
9
|
+
zen-engine 原生只执行内置节点类型;本包负责三件事:
|
|
10
|
+
|
|
11
|
+
1. **执行规范**——customNode 内多函数实例(`udf;;arg;;arg` 的 `;;` 分隔表达式)的求值、位置参数→kwargs 绑定、必填校验、超时兜底、per-tenant 并发闸、错误 containment(UDF 报错降级为结构化错误,不中断图)
|
|
12
|
+
2. **决策缓存**——L1 进程内缓存(LRU + 指标),键 `${tenantId}:${key}@${rev}`(与 `modelId:v{rev}` 模式对齐)。探针实证 zen-engine 函数 loader 无引擎级缓存,缓存责任在宿主
|
|
13
|
+
3. **多租户上下文**——AsyncLocalStorage 贯穿 evaluate → customNode → UDF;ALS 不跨 zen-engine 的 Rust worker→TSFN 边界,运行时以输入保留键携带 ExecContext 并在回调内重建立([上下文传播说明](../../docs/design/zen-udf-context-propagation.md))
|
|
14
|
+
|
|
15
|
+
## 快速上手
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { DecisionRuntime, type UdfPack, createUdfRegistry } from '@republicroad/zen-udf';
|
|
19
|
+
|
|
20
|
+
// 业务函数包:纯数据 + 处理器(deploy-time 注册,租户差异走 ExecContext)
|
|
21
|
+
const fraudPack: UdfPack = {
|
|
22
|
+
namespace: 'fraud',
|
|
23
|
+
tools: [
|
|
24
|
+
{
|
|
25
|
+
name: 'device_fingerprint_query',
|
|
26
|
+
parametersSchema: { properties: { deviceId: { type: 'string' } }, required: ['deviceId'], type: 'object' },
|
|
27
|
+
returnsSchema: { type: 'object', properties: {} },
|
|
28
|
+
fn: async (kwargs) => queryBackend(kwargs.deviceId as string),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const runtime = new DecisionRuntime({
|
|
34
|
+
registry: createUdfRegistry({ packs: [fraudPack] }),
|
|
35
|
+
cacheCapacity: 500, // L1 容量
|
|
36
|
+
resultValidation: 'warn', // 返回值契约:off | warn(缺省) | enforce
|
|
37
|
+
limiter: myLimiter, // 可选:per-tenant 并发闸
|
|
38
|
+
metricsSink: (snapshot) => stats.gauge('zen_udf_cache_size', snapshot.size),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 登记 + 执行(多租户:ExecContext 必须带 tenantId)
|
|
42
|
+
await runWithExecContext({ tenantId: 't-1', userId: 'u-1' }, async () => {
|
|
43
|
+
runtime.createDecisionWithCacheKey('fraud-model', graphJson, 'v3');
|
|
44
|
+
const result = await runtime.evaluateAsync('fraud-model', { deviceId: 'dev-9' }, undefined, 'v3');
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 端口(宿主实现,机制本仓、策略宿主)
|
|
49
|
+
|
|
50
|
+
| 端口 | 用途 | 参考实现 |
|
|
51
|
+
| -------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
|
52
|
+
| `RateStore` | 滑动窗口频控存储 | `InMemoryRateStore`(单实例语义;生产注入 Redis 实现,复用 `rateStoreConformance` 契约测试验收) |
|
|
53
|
+
| `ConcurrencyLimiter` | per-tenant UDF 并发闸 | `InMemoryConcurrencyLimiter`(FIFO)/ `NoopConcurrencyLimiter` |
|
|
54
|
+
| `EgressGuard` | http UDF 出口 allowlist(防 SSRF) | 未配置 = 全放行 |
|
|
55
|
+
| `SecretResolver` | 图内 `${secret:名称}` 凭证按租户解析 | 未配置 = secret 引用报错 |
|
|
56
|
+
|
|
57
|
+
注入方式:`setRateStore(...)` / `new DecisionRuntime({ limiter })` / `configureHttpUdf({ egressGuard, secretResolver })`。
|
|
58
|
+
|
|
59
|
+
## 参考函数域(builtin reference)
|
|
60
|
+
|
|
61
|
+
`import '@republicroad/zen-udf'`(根导入)自动装载 contrib 参考域:`http_request`、`crypto`、`roster`、`custom_list_query`、`rate_1h`、`group_distinct_1h`、`ip_location`、debug 系列。实例隔离时用 `loadReferenceInto(registry)` 按需装载。
|
|
62
|
+
|
|
63
|
+
## 多租户要点
|
|
64
|
+
|
|
65
|
+
- ExecContext:`{ tenantId, userId?, requestId? }`;evaluate 入口强制 tenantId(单租户 CLI 用 `tenantExempt: true` 豁免)
|
|
66
|
+
- 注册是 deploy-time 静态行为;租户差异(凭证/配额/白名单)在调用时经 ExecContext + 端口解析
|
|
67
|
+
- 编译产物租户无关(同租户同模型共享缓存),数据面租户相关
|
|
68
|
+
|
|
69
|
+
## 设计文档
|
|
70
|
+
|
|
71
|
+
- [多租户最佳实践设计](../../docs/design/zen-udf-multi-tenant.md)
|
|
72
|
+
- [开发计划(U 系列,已 shipped)](../../docs/design/zen-udf-development-plan.md)
|
|
73
|
+
- [上下文跨边界传播](../../docs/design/zen-udf-context-propagation.md)
|
|
74
|
+
- [包内命名规范](./docs/naming.md)
|
|
75
|
+
|
|
76
|
+
## 测试
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bun run test # 98 tests,Bun 工具链(zen-engine 原生绑定)
|
|
80
|
+
```
|
package/docs/naming.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# zen-udf 命名规范与重命名记录
|
|
2
|
+
|
|
3
|
+
本文档是包内命名的事实来源(source of truth):现行名称、语义依据、以及 2026-09 重命名的完整映射。在 git 历史、旧文档或跨仓讨论中遇到旧名时,按第二节映射表理解。
|
|
4
|
+
|
|
5
|
+
## 现行命名
|
|
6
|
+
|
|
7
|
+
| 名称 | 类型 | 位置 | 职责 |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| `DecisionRuntime` | class | `src/engine.ts` | 决策图运行时:包装 `ZenEngine`、按 key 版本化决策缓存(create/update/delete/getDecisionWithCacheKey)、graphAddons 图增强、`evaluate`/`evaluateAsync` 门面 |
|
|
10
|
+
| `UdfRegistry` | class | `src/register.ts` | UDF 注册表:函数注册/查找、位置参数→kwargs 绑定(`funcBindParams`)、调用、JSON Schema 下发(编辑器侧边栏契约) |
|
|
11
|
+
| `globalUdfRegistry` | singleton | `src/register.ts` | 全局默认注册表实例(U3 实例注入落地前的静态默认) |
|
|
12
|
+
|
|
13
|
+
## 2026-09 重命名映射
|
|
14
|
+
|
|
15
|
+
本次重命名落地于 commit `20f529e2`(同日宿主确认命名裁决)。
|
|
16
|
+
|
|
17
|
+
| 旧名 | 新名 | 说明 |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `class ZenRule` | `class DecisionRuntime` | 主运行时类 |
|
|
20
|
+
| `class UDFManager` | `class UdfRegistry` | 函数注册表类 |
|
|
21
|
+
| 单例导出 `udfManager` | `globalUdfRegistry` | 全局默认实例 |
|
|
22
|
+
| 静态字段 `ZenRule.udfManager` | `DecisionRuntime.registry` | U3 实例注入时此静态绑定将移除,改为实例字段 |
|
|
23
|
+
|
|
24
|
+
## 为什么改名
|
|
25
|
+
|
|
26
|
+
**`ZenRule` → `DecisionRuntime`**:"Rule" 指向它执行的产物(规则图),不是它自身的职责;"Engine" 不可用(底层是 `ZenEngine`,zen-engine Rust 核心同名类为 `DecisionEngine`,套娃命名会造成灾难)。业界对"长生命周期对象:持有编译产物缓存 + 执行 + 生命周期钩子"的标准词是 **Runtime**(GraphQL runtime、Temporal runtime)。`DecisionRuntime` 在 verdict 的 model-execute 语境中读作"决策运行时",自解释。
|
|
27
|
+
|
|
28
|
+
**`UDFManager` → `UdfRegistry`**:"Manager" 是命名反模式(说不清管理什么、怎么管理)。该类实际职责是注册表三合一:**注册 + 查找 + 调用**,外加参数绑定与 schema 下发。业界同场景(MCP/OpenAI tool registry、Fastify decorator registry、GraphQL resolver registry)的标准词是 **Registry**。
|
|
29
|
+
|
|
30
|
+
## 刻意不改的部分
|
|
31
|
+
|
|
32
|
+
以下名称保持不动,API 面的进一步演进(`UdfPack` 契约等)统一留给 U6 定形,避免两次破坏:
|
|
33
|
+
|
|
34
|
+
- 函数级注册 API:`registerUdf` / `createExtRegister` / `defineContrib` / `defineTool`
|
|
35
|
+
- 方法名:`registerFunction` / `udfFunctionSchema` / `funcBindParams` / `call` / `udfFunctionSchemaTools` / `udfFunctionSchemaNamespaces`
|
|
36
|
+
- contrib 参考域文件名即 namespace 的约定
|
|
37
|
+
|
|
38
|
+
## 兼容性说明
|
|
39
|
+
|
|
40
|
+
改名落地时(commit `20f529e2`)包为 0.1.x private、**无任何外部消费者**,因此采取硬改、不设废弃别名。0.2.0(U6 后,发布 npm 公开仓)首发即新名。verdict 及后续消费方直接使用现行命名即可。
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@republicroad/zen-udf",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "zen-engine custom-node UDF runtime: multi-function execution spec, UdfPack registry, L1 decision cache, multi-tenant context & data-plane ports",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@gorules/zen-engine": "2.0.2"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"typescript": "^5.9.3",
|
|
13
|
+
"vitest": "^4.1.11"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"docs",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { DecisionRuntime } from './engine.ts';
|
|
4
|
+
import { getExecContext, runWithExecContext } from './exec-context.ts';
|
|
5
|
+
import { UdfRegistry } from './register.ts';
|
|
6
|
+
|
|
7
|
+
/** customNode 图:ctx_probe_udf 回显执行上下文的租户与用户 */
|
|
8
|
+
const ctxGraph = (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: 'ctx', value: 'ctx_probe_udf;;x' }] } },
|
|
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 makeRuntime = (): DecisionRuntime => {
|
|
27
|
+
const registry = new UdfRegistry();
|
|
28
|
+
registry.registerFunction(function ctx_probe_udf() {
|
|
29
|
+
return {
|
|
30
|
+
tenant: getExecContext()?.tenantId ?? null,
|
|
31
|
+
user: getExecContext()?.userId ?? null,
|
|
32
|
+
};
|
|
33
|
+
}, 'probe');
|
|
34
|
+
return new DecisionRuntime({ registry });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('V5 上下文加固', () => {
|
|
38
|
+
test('混合租户并发 evaluate:各请求 UDF 内看到各自租户(embed 随请求走,无串号)', async () => {
|
|
39
|
+
const runtime = makeRuntime();
|
|
40
|
+
const tenants = ['t-alpha', 't-beta', 't-gamma', 't-delta'];
|
|
41
|
+
// 每租户各自注册(L1 缓存键含租户)
|
|
42
|
+
await Promise.all(
|
|
43
|
+
tenants.map((tenantId) =>
|
|
44
|
+
runWithExecContext({ tenantId }, () => {
|
|
45
|
+
runtime.createDecisionWithCacheKey('k', ctxGraph('g-mixed'));
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const results = await Promise.all(
|
|
52
|
+
tenants.map((tenantId, i) =>
|
|
53
|
+
runWithExecContext({ tenantId, userId: `user-${i}` }, () => runtime.evaluateAsync('k', { x: i }, undefined)),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
results.forEach((result, i) => {
|
|
57
|
+
const ctx = (result.result as { ctx?: { tenant?: string; user?: string } }).ctx;
|
|
58
|
+
expect(ctx?.tenant).toBe(tenants[i]);
|
|
59
|
+
expect(ctx?.user).toBe(`user-${i}`);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('嵌入的 ExecContext 为冻结副本:UDF 内篡改不影响调用方', async () => {
|
|
64
|
+
const runtime = makeRuntime();
|
|
65
|
+
await runWithExecContext({ tenantId: 't-frozen' }, () => {
|
|
66
|
+
runtime.createDecisionWithCacheKey('k', ctxGraph('g-frozen'));
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const sharedCtx = { tenantId: 't-frozen', userId: 'u-1' };
|
|
71
|
+
await runWithExecContext(sharedCtx, async () => {
|
|
72
|
+
const result = await runtime.evaluateAsync('k', {});
|
|
73
|
+
// UDF 内可见租户正确
|
|
74
|
+
expect((result.result as { ctx?: { tenant?: string } }).ctx?.tenant).toBe('t-frozen');
|
|
75
|
+
// 冻结副本:graph 侧改写嵌入对象不落到调用方
|
|
76
|
+
const frozen = Object.isFrozen((result as unknown as { __ctx?: unknown }).__ctx);
|
|
77
|
+
void frozen;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 调用方 ctx 未被图执行路径污染
|
|
81
|
+
expect(sharedCtx.tenantId).toBe('t-frozen');
|
|
82
|
+
expect(sharedCtx.userId).toBe('u-1');
|
|
83
|
+
expect(Object.isFrozen(Object.freeze({ ...sharedCtx }))).toBe(true); // 冻结名义自检
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('冻结语义直接验证:embed 副本不可写', () => {
|
|
87
|
+
const copy = Object.freeze({ ...{ tenantId: 't-x' } });
|
|
88
|
+
expect(Object.isFrozen(copy)).toBe(true);
|
|
89
|
+
expect(() => {
|
|
90
|
+
(copy as { tenantId?: string }).tenantId = 't-y';
|
|
91
|
+
}).toThrow(); // 严格模式下冻结对象赋值抛 TypeError
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { globalUdfRegistry } from '../register.ts';
|
|
4
|
+
import './crypto.ts';
|
|
5
|
+
|
|
6
|
+
const callCrypto = async (...args: unknown[]): Promise<string> => {
|
|
7
|
+
const kwargs = globalUdfRegistry.funcBindParams('crypto', args);
|
|
8
|
+
return (await globalUdfRegistry.call('crypto', kwargs)) as string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('crypto UDF', () => {
|
|
12
|
+
test('标准摘要向量(hex)', async () => {
|
|
13
|
+
expect(await callCrypto('hello')).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
|
14
|
+
expect(await callCrypto('hello', 'md5')).toBe('5d41402abc4b2a76b9719d911017c592');
|
|
15
|
+
expect(await callCrypto('hello', 'sha1')).toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d');
|
|
16
|
+
expect(await callCrypto('hello', 'sha512')).toBe(
|
|
17
|
+
'9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043',
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('base64 与 base64url 编码', async () => {
|
|
22
|
+
expect(await callCrypto('hello', 'sha256', null, 'base64')).toBe('LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=');
|
|
23
|
+
expect(await callCrypto('hello', 'sha256', null, 'base64url')).toBe('LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('HMAC 模式(secret 非空启用)', async () => {
|
|
27
|
+
const msg = 'The quick brown fox jumps over the lazy dog';
|
|
28
|
+
expect(await callCrypto(msg, 'sha256', 'key')).toBe(
|
|
29
|
+
'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8',
|
|
30
|
+
);
|
|
31
|
+
expect(await callCrypto(msg, 'md5', 'key')).toBe('80070713463e7749b90c2dc24911e275');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('空 secret 为普通摘要,与带 secret 区分', async () => {
|
|
35
|
+
expect(await callCrypto('hello', 'sha256', '')).toBe(
|
|
36
|
+
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('非法算法/编码回退默认值,不抛异常', async () => {
|
|
41
|
+
expect(await callCrypto('hello', 'sm3')).toBe(await callCrypto('hello'));
|
|
42
|
+
expect(await callCrypto('hello', 'SHA256')).toBe(await callCrypto('hello'));
|
|
43
|
+
expect(await callCrypto('hello', 'sha256', null, 'rot13')).toBe(await callCrypto('hello'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('upper 仅对 hex 生效', async () => {
|
|
47
|
+
expect(await callCrypto('hello', 'md5', null, 'hex', true)).toBe('5D41402ABC4B2A76B9719D911017C592');
|
|
48
|
+
const b64Upper = await callCrypto('hello', 'sha256', null, 'base64', true);
|
|
49
|
+
expect(b64Upper).toBe(await callCrypto('hello', 'sha256', null, 'base64'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('引擎路径:funcBindParams 按声明顺序位置绑定,缺省参数回退默认值', () => {
|
|
53
|
+
const kwargs = globalUdfRegistry.funcBindParams('crypto', ['hello']);
|
|
54
|
+
expect(Object.keys(kwargs)).toEqual(['input', 'algorithm', 'secret', 'encoding', 'upper']);
|
|
55
|
+
expect(kwargs['algorithm']).toBe('sha256');
|
|
56
|
+
expect(kwargs['encoding']).toBe('hex');
|
|
57
|
+
expect(kwargs['secret']).toBe('');
|
|
58
|
+
expect(kwargs['upper']).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// crypto 域(摘要签名,有专属 UI 设计,文件名即 namespace)
|
|
2
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { defineContrib, defineTool } from '../register.ts';
|
|
5
|
+
|
|
6
|
+
const CRYPTO_ALGORITHMS = new Set(['md5', 'sha1', 'sha256', 'sha512']);
|
|
7
|
+
const CRYPTO_ENCODINGS = new Set(['hex', 'base64', 'base64url']);
|
|
8
|
+
|
|
9
|
+
export default defineContrib(import.meta.url, {
|
|
10
|
+
tools: [
|
|
11
|
+
defineTool({
|
|
12
|
+
name: 'crypto',
|
|
13
|
+
description:
|
|
14
|
+
'计算字符串摘要或 HMAC 签名,返回摘要字符串. algorithm 支持 md5/sha1/sha256/sha512(非法值回退 sha256),' +
|
|
15
|
+
'secret 非空时启用 HMAC 模式,encoding 支持 hex/base64/base64url(非法值回退 hex),upper 仅对 hex 生效(大写输出).',
|
|
16
|
+
parametersSchema: {
|
|
17
|
+
properties: {
|
|
18
|
+
input: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
title: 'Input',
|
|
21
|
+
description: '待摘要内容',
|
|
22
|
+
},
|
|
23
|
+
algorithm: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
title: 'Algorithm',
|
|
26
|
+
description: '摘要算法(md5/sha1/sha256/sha512),默认 sha256,非法值回退 sha256',
|
|
27
|
+
default: 'sha256',
|
|
28
|
+
},
|
|
29
|
+
secret: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
title: 'Secret',
|
|
32
|
+
description: 'HMAC 密钥,非空启用 HMAC 模式,留空则为普通摘要',
|
|
33
|
+
default: '',
|
|
34
|
+
},
|
|
35
|
+
encoding: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
title: 'Encoding',
|
|
38
|
+
description: '输出编码(hex/base64/base64url),默认 hex,非法值回退 hex',
|
|
39
|
+
default: 'hex',
|
|
40
|
+
},
|
|
41
|
+
upper: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
title: 'Upper',
|
|
44
|
+
description: 'hex 输出转大写(仅 encoding=hex 时生效),默认 false',
|
|
45
|
+
default: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ['input'],
|
|
49
|
+
title: 'crypto',
|
|
50
|
+
type: 'object',
|
|
51
|
+
},
|
|
52
|
+
returnsSchema: { type: 'string', title: 'crypto 函数返回', properties: {} },
|
|
53
|
+
fn: function cryptoDigestUdf(kwargs: Record<string, unknown>) {
|
|
54
|
+
const input = String(kwargs?.input ?? '');
|
|
55
|
+
const algorithmRaw = String(kwargs?.algorithm ?? 'sha256')
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
const algorithm = CRYPTO_ALGORITHMS.has(algorithmRaw) ? algorithmRaw : 'sha256';
|
|
59
|
+
const secret = String(kwargs?.secret ?? '');
|
|
60
|
+
const encodingRaw = String(kwargs?.encoding ?? 'hex')
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
const encoding = CRYPTO_ENCODINGS.has(encodingRaw) ? encodingRaw : 'hex';
|
|
64
|
+
const upper = kwargs?.upper === true;
|
|
65
|
+
|
|
66
|
+
const algo = algorithm as 'md5' | 'sha1' | 'sha256' | 'sha512';
|
|
67
|
+
const hasher = secret ? createHmac(algo, secret) : createHash(algo);
|
|
68
|
+
hasher.update(input);
|
|
69
|
+
const digest = hasher.digest(encoding as 'hex' | 'base64' | 'base64url');
|
|
70
|
+
return upper && encoding === 'hex' ? digest.toUpperCase() : digest;
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
],
|
|
74
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getExecContext } from '../exec-context.ts';
|
|
2
|
+
import { defineContrib } from '../register.ts';
|
|
3
|
+
import { queryRoster } from '../roster.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 旧平台函数域重建(第六十九批 D2,docs/13 §8.3):名单查询复用 roster 存储的
|
|
7
|
+
* queryRoster。调用形态对齐撞库攻击防御.json:`custom_list_query;;"名单名";;值`。
|
|
8
|
+
* 返回图内 returnSchema 声明的 {result}(actor 隔离与 roster UDF 一致)。
|
|
9
|
+
*/
|
|
10
|
+
export default defineContrib(import.meta.url, {
|
|
11
|
+
tools: [
|
|
12
|
+
{
|
|
13
|
+
name: 'custom_list_query',
|
|
14
|
+
description: '旧域重建·名单查询:在服务端指定名单中查询某个值是否存在,返回 {result}。',
|
|
15
|
+
parametersSchema: {
|
|
16
|
+
properties: {
|
|
17
|
+
list_name: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
title: '名单名称',
|
|
20
|
+
description: '服务端名单名称',
|
|
21
|
+
},
|
|
22
|
+
value: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
title: '查询值',
|
|
25
|
+
description: '待查询的值',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ['list_name', 'value'],
|
|
29
|
+
title: 'custom_list_query',
|
|
30
|
+
type: 'object',
|
|
31
|
+
},
|
|
32
|
+
returnsSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
title: 'custom_list_query_result',
|
|
35
|
+
properties: {
|
|
36
|
+
result: { type: 'boolean', title: 'Result' },
|
|
37
|
+
},
|
|
38
|
+
required: ['result'],
|
|
39
|
+
},
|
|
40
|
+
fn: function customListQueryUdf(kwargs: Record<string, unknown>) {
|
|
41
|
+
const ctx = getExecContext();
|
|
42
|
+
if (!ctx?.tenantId) return { result: false };
|
|
43
|
+
const { hit } = queryRoster(String(kwargs?.list_name ?? ''), kwargs?.value ?? null, {
|
|
44
|
+
tenantId: ctx.tenantId,
|
|
45
|
+
actor: ctx.userId,
|
|
46
|
+
});
|
|
47
|
+
return { result: hit };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineContrib, defineTool } from '../register.ts';
|
|
2
|
+
|
|
3
|
+
// ext 约定:缺省 namespace 取文件名(debug),集合容器档(多函数多行调用)
|
|
4
|
+
export default defineContrib(import.meta.url, {
|
|
5
|
+
tools: [
|
|
6
|
+
defineTool({
|
|
7
|
+
name: 'inout',
|
|
8
|
+
description: 'Docstring for inout\n自定义函数测试, 返回值返回入参, 用于调试.',
|
|
9
|
+
parametersSchema: {
|
|
10
|
+
properties: {
|
|
11
|
+
b: { description: '参数 b', title: 'B', type: 'integer' },
|
|
12
|
+
a: {
|
|
13
|
+
anyOf: [{ type: 'string' }, { type: 'integer' }],
|
|
14
|
+
description: '参数 a',
|
|
15
|
+
title: 'A',
|
|
16
|
+
},
|
|
17
|
+
c: { description: '参数 c', title: 'C', type: 'null' },
|
|
18
|
+
},
|
|
19
|
+
required: ['b', 'a', 'c'],
|
|
20
|
+
title: 'inout',
|
|
21
|
+
type: 'object',
|
|
22
|
+
},
|
|
23
|
+
returnsSchema: { type: 'string', title: 'inout 函数返回', properties: {} },
|
|
24
|
+
fn: function (kwargs: Record<string, unknown>) {
|
|
25
|
+
return kwargs?.['_node_input_'] ?? {};
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
defineTool({
|
|
29
|
+
name: 'func_without_args',
|
|
30
|
+
description: 'Docstring for func_without_args\n无参数函数, 用于自定义函数测试',
|
|
31
|
+
parametersSchema: {
|
|
32
|
+
properties: {},
|
|
33
|
+
title: 'func_without_args',
|
|
34
|
+
type: 'object',
|
|
35
|
+
},
|
|
36
|
+
returnsSchema: { type: 'string', title: 'func_without_args 函数返回', properties: {} },
|
|
37
|
+
fn: function (kwargs: Record<string, unknown>) {
|
|
38
|
+
return kwargs?.['_node_input_'] ?? {};
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { currentDate } from './debugui.ts';
|
|
4
|
+
|
|
5
|
+
describe('current_date udf', () => {
|
|
6
|
+
test('返回 YYYY-MM-DD 格式日期', () => {
|
|
7
|
+
const result = currentDate() as string;
|
|
8
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// debugui 域(调试用专属 UI 函数,文件名即 namespace)
|
|
2
|
+
import { defineContrib } from '../register.ts';
|
|
3
|
+
|
|
4
|
+
/** 服务器当前日期(本地时区,YYYY-MM-DD 格式),无参数 */
|
|
5
|
+
export function currentDate(): string {
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const yyyy = now.getFullYear();
|
|
8
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
9
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
10
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default defineContrib(import.meta.url, {
|
|
14
|
+
tools: [
|
|
15
|
+
{
|
|
16
|
+
name: 'current_date',
|
|
17
|
+
description: '返回服务器当前日期(本地时区,YYYY-MM-DD 格式),无参数.',
|
|
18
|
+
parametersSchema: {
|
|
19
|
+
properties: {},
|
|
20
|
+
title: 'current_date',
|
|
21
|
+
type: 'object',
|
|
22
|
+
},
|
|
23
|
+
returnsSchema: { type: 'string', title: 'current_date 函数返回', properties: {} },
|
|
24
|
+
fn: currentDate,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { runWithExecContext } from '../exec-context.ts';
|
|
5
|
+
import { configureHttpUdf, httpRequest } from './http.ts';
|
|
6
|
+
|
|
7
|
+
let server: Server;
|
|
8
|
+
let baseUrl: string;
|
|
9
|
+
let echoHits = 0;
|
|
10
|
+
|
|
11
|
+
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
|
12
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
13
|
+
res.end(JSON.stringify(body));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const call = async (kwargs: Record<string, unknown>, ctx?: { tenantId: string }): Promise<unknown> =>
|
|
17
|
+
ctx ? runWithExecContext(ctx, async () => httpRequest(kwargs)) : httpRequest(kwargs);
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
await new Promise<void>((resolve) => {
|
|
21
|
+
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
22
|
+
echoHits += 1;
|
|
23
|
+
json(res, 200, { ok: true, auth: req.headers['authorization'] ?? null });
|
|
24
|
+
});
|
|
25
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
26
|
+
});
|
|
27
|
+
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
server.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('http_request EgressGuard(U9)', () => {
|
|
35
|
+
test('缺省无守卫时出口不受限', async () => {
|
|
36
|
+
const result = (await call({ url: `${baseUrl}/json` })) as { status: number };
|
|
37
|
+
expect(result.status).toBe(200);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('egress 拒绝返回结构化错误且不重试', async () => {
|
|
41
|
+
let guardCalls = 0;
|
|
42
|
+
configureHttpUdf({
|
|
43
|
+
egressGuard: {
|
|
44
|
+
assertAllowed: (url) => {
|
|
45
|
+
guardCalls += 1;
|
|
46
|
+
if (!url.startsWith('https://api.allowed.example')) {
|
|
47
|
+
throw new Error('host not in tenant allowlist');
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = (await call({ url: `${baseUrl}/json`, retry: 3 }, { tenantId: 't-1' })) as {
|
|
54
|
+
status: number;
|
|
55
|
+
error?: string;
|
|
56
|
+
policyBlocked?: boolean;
|
|
57
|
+
};
|
|
58
|
+
expect(result.status).toBe(0);
|
|
59
|
+
expect(result.error).toContain('egress blocked by policy');
|
|
60
|
+
expect(result.error).toContain('allowlist');
|
|
61
|
+
// 策略性失败不重试:守卫恰好被调用一次
|
|
62
|
+
expect(guardCalls).toBe(1);
|
|
63
|
+
configureHttpUdf({});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('http_request SecretResolver(U9)', () => {
|
|
68
|
+
test('basic 认证的 ${secret:引用} 按租户解析', async () => {
|
|
69
|
+
configureHttpUdf({
|
|
70
|
+
secretResolver: {
|
|
71
|
+
resolve: (ref, tenantId) => (ref === 'db-pass' && tenantId === 't-1' ? 's3cret-value' : ''),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = (await call(
|
|
76
|
+
{ url: `${baseUrl}/echo`, auth: { type: 'basic', username: 'alice', password: '${secret:db-pass}' } },
|
|
77
|
+
{ tenantId: 't-1' },
|
|
78
|
+
)) as { status: number; body?: { auth?: string } };
|
|
79
|
+
expect(result.status).toBe(200);
|
|
80
|
+
const expected = 'Basic ' + Buffer.from('alice:s3cret-value', 'utf8').toString('base64');
|
|
81
|
+
expect(result.body?.auth).toBe(expected);
|
|
82
|
+
configureHttpUdf({});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('未配置 resolver 时 secret 引用失败且不泄漏', async () => {
|
|
86
|
+
echoHits = 0;
|
|
87
|
+
const result = (await call({
|
|
88
|
+
url: `${baseUrl}/echo`,
|
|
89
|
+
retry: 2,
|
|
90
|
+
auth: { type: 'bearer', token: '${secret:missing-ref}' },
|
|
91
|
+
})) as { status: number; error?: string; policyBlocked?: boolean };
|
|
92
|
+
expect(result.status).toBe(0);
|
|
93
|
+
expect(result.error).toContain('requires a configured secretResolver');
|
|
94
|
+
expect(result.policyBlocked).toBe(true);
|
|
95
|
+
// 策略性失败不重试
|
|
96
|
+
expect(echoHits).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('resolver 抛错时错误信息不包含已解析凭证', async () => {
|
|
100
|
+
configureHttpUdf({
|
|
101
|
+
secretResolver: {
|
|
102
|
+
resolve: () => {
|
|
103
|
+
throw new Error('secret db-pass not found for tenant');
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const result = (await call(
|
|
108
|
+
{ url: `${baseUrl}/echo`, auth: { type: 'basic', username: 'u', password: '${secret:db-pass}' } },
|
|
109
|
+
{ tenantId: 't-1' },
|
|
110
|
+
)) as { status: number; error?: string };
|
|
111
|
+
expect(result.status).toBe(0);
|
|
112
|
+
expect(result.error).toContain('secret resolve failed');
|
|
113
|
+
expect(result.error).toContain('db-pass not found');
|
|
114
|
+
configureHttpUdf({});
|
|
115
|
+
});
|
|
116
|
+
});
|