@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
@@ -0,0 +1,230 @@
1
+ import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
2
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
3
+
4
+ import { globalUdfRegistry } from '../register.ts';
5
+ import { httpRequest } from './http.ts';
6
+
7
+ let server: Server;
8
+ let baseUrl: string;
9
+ let closedUrl: string;
10
+
11
+ /** /flaky 连续失败计数(每个用例开始前重置);/missing 命中计数(用于断言 4xx 不重试) */
12
+ let flakyRemaining = 0;
13
+ let missingHits = 0;
14
+
15
+ const json = (res: ServerResponse, status: number, body: unknown): void => {
16
+ res.writeHead(status, { 'content-type': 'application/json' });
17
+ res.end(JSON.stringify(body));
18
+ };
19
+
20
+ const readBody = (req: IncomingMessage): Promise<string> =>
21
+ new Promise((resolve) => {
22
+ let data = '';
23
+ req.on('data', (chunk: Buffer | string) => (data += chunk));
24
+ req.on('end', () => resolve(data));
25
+ });
26
+
27
+ beforeAll(async () => {
28
+ server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
29
+ const url = new URL(req.url ?? '/', 'http://localhost');
30
+ if (url.pathname === '/json') {
31
+ json(res, 200, { ok: true, q: url.searchParams.get('q') });
32
+ return;
33
+ }
34
+ if (url.pathname === '/params') {
35
+ const query: Record<string, string> = {};
36
+ url.searchParams.forEach((value, key) => {
37
+ query[key] = value;
38
+ });
39
+ json(res, 200, query);
40
+ return;
41
+ }
42
+ if (url.pathname === '/echo') {
43
+ const bodyText = await readBody(req);
44
+ json(res, 200, {
45
+ method: req.method,
46
+ contentType: req.headers['content-type'] ?? null,
47
+ authorization: req.headers['authorization'] ?? null,
48
+ body: bodyText ? JSON.parse(bodyText) : null,
49
+ });
50
+ return;
51
+ }
52
+ if (url.pathname === '/slow') {
53
+ setTimeout(() => json(res, 200, 'late'), 500);
54
+ return;
55
+ }
56
+ if (url.pathname === '/flaky') {
57
+ if (flakyRemaining > 0) {
58
+ flakyRemaining -= 1;
59
+ res.writeHead(500);
60
+ res.end('boom');
61
+ return;
62
+ }
63
+ json(res, 200, { ok: true });
64
+ return;
65
+ }
66
+ if (url.pathname === '/missing') {
67
+ missingHits += 1;
68
+ res.writeHead(404);
69
+ res.end('not found');
70
+ return;
71
+ }
72
+ res.writeHead(404);
73
+ res.end('not found');
74
+ });
75
+ server.listen(0, '127.0.0.1', () => {
76
+ baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
77
+ });
78
+
79
+ // closedUrl:起服后立即关闭,用于连接拒绝场景
80
+ const throwaway = createServer((_req, res) => {
81
+ res.end('ok');
82
+ });
83
+ await new Promise<void>((resolve) => throwaway.listen(0, '127.0.0.1', resolve));
84
+ closedUrl = `http://127.0.0.1:${(throwaway.address() as { port: number }).port}`;
85
+ throwaway.close();
86
+ });
87
+
88
+ afterAll(() => {
89
+ server.close();
90
+ });
91
+
92
+ const callUdf = async (kwargs: Record<string, unknown>) => (await httpRequest(kwargs)) as Record<string, unknown>;
93
+
94
+ describe('http_request udf', () => {
95
+ test('GET 解析 JSON 响应体', async () => {
96
+ const result = await callUdf({ url: `${baseUrl}/json?q=abc` });
97
+ expect(result['status']).toBe(200);
98
+ expect(result['error']).toBeUndefined();
99
+ expect(result['body']).toEqual({ ok: true, q: 'abc' });
100
+ const headers = result['headers'] as Record<string, string>;
101
+ expect(headers['content-type']).toContain('application/json');
102
+ });
103
+
104
+ test('POST 自动 JSON 序列化并补充 content-type', async () => {
105
+ const result = await callUdf({ url: `${baseUrl}/echo`, method: 'POST', body: { a: 1 } });
106
+ expect(result['status']).toBe(200);
107
+ expect(result['body']).toEqual({
108
+ method: 'POST',
109
+ contentType: 'application/json',
110
+ authorization: null,
111
+ body: { a: 1 },
112
+ });
113
+ });
114
+
115
+ test('显式 content-type 不被覆盖,空对象视为无请求体', async () => {
116
+ const withBody = await callUdf({
117
+ url: `${baseUrl}/echo`,
118
+ method: 'POST',
119
+ headers: { 'content-type': 'text/plain' },
120
+ body: { a: 1 },
121
+ });
122
+ expect((withBody['body'] as Record<string, unknown>)['contentType']).toBe('text/plain');
123
+
124
+ const emptyBody = await callUdf({ url: `${baseUrl}/echo`, method: 'POST', body: {} });
125
+ expect((emptyBody['body'] as Record<string, unknown>)['body']).toBeNull();
126
+ });
127
+
128
+ test('GET/HEAD 忽略 body,非 2xx 正常返回', async () => {
129
+ const getWithBody = await callUdf({ url: `${baseUrl}/echo`, body: { a: 1 } });
130
+ expect(getWithBody['status']).toBe(200);
131
+ expect((getWithBody['body'] as Record<string, unknown>)['method']).toBe('GET');
132
+ expect((getWithBody['body'] as Record<string, unknown>)['body']).toBeNull();
133
+
134
+ const notFound = await callUdf({ url: `${baseUrl}/missing` });
135
+ expect(notFound['status']).toBe(404);
136
+ expect(notFound['body']).toBe('not found');
137
+ expect(notFound['error']).toBeUndefined();
138
+ });
139
+
140
+ test('连接拒绝返回结构化错误而非抛出', async () => {
141
+ const result = await callUdf({ url: closedUrl });
142
+ expect(result['status']).toBe(0);
143
+ expect(typeof result['error']).toBe('string');
144
+ expect(String(result['error']).length).toBeGreaterThan(0);
145
+ });
146
+
147
+ test('非法 HTTP 方法被白名单拦截', async () => {
148
+ const result = await callUdf({ url: `${baseUrl}/echo`, method: 'TRACE' });
149
+ expect(result['status']).toBe(0);
150
+ expect(result['error']).toBe("unsupported http method 'TRACE'");
151
+ });
152
+
153
+ test('params 合并进 URL 查询串并覆盖同名参数', async () => {
154
+ const result = await callUdf({
155
+ url: `${baseUrl}/params?existing=keep&q=old`,
156
+ params: { q: 'new', page: '2' },
157
+ });
158
+ expect(result['status']).toBe(200);
159
+ expect(result['body']).toEqual({ existing: 'keep', q: 'new', page: '2' });
160
+ });
161
+
162
+ test('basic 认证生成 Authorization 头,显式头优先于 auth 配置', async () => {
163
+ const basic = await callUdf({
164
+ url: `${baseUrl}/echo`,
165
+ auth: { type: 'basic', username: 'alice', password: 's3cret' },
166
+ });
167
+ expect((basic['body'] as Record<string, unknown>)['authorization']).toBe(
168
+ `Basic ${Buffer.from('alice:s3cret', 'utf8').toString('base64')}`,
169
+ );
170
+
171
+ const overridden = await callUdf({
172
+ url: `${baseUrl}/echo`,
173
+ headers: { authorization: 'Bearer manual' },
174
+ auth: { type: 'bearer', token: 'auto' },
175
+ });
176
+ expect((overridden['body'] as Record<string, unknown>)['authorization']).toBe('Bearer manual');
177
+
178
+ const bearer = await callUdf({ url: `${baseUrl}/echo`, auth: { type: 'bearer', token: 'tk-1' } });
179
+ expect((bearer['body'] as Record<string, unknown>)['authorization']).toBe('Bearer tk-1');
180
+ });
181
+
182
+ test('timeout 超时返回结构化错误', async () => {
183
+ const result = await callUdf({ url: `${baseUrl}/slow`, timeout: 100 });
184
+ expect(result['status']).toBe(0);
185
+ expect(typeof result['error']).toBe('string');
186
+ });
187
+
188
+ test('retry 仅对 5xx 重试直至成功,4xx 不重试', async () => {
189
+ flakyRemaining = 2;
190
+ const recovered = await callUdf({ url: `${baseUrl}/flaky`, retry: 2 });
191
+ expect(recovered['status']).toBe(200);
192
+ expect(recovered['body']).toEqual({ ok: true });
193
+
194
+ flakyRemaining = 5;
195
+ const exhausted = await callUdf({ url: `${baseUrl}/flaky`, retry: 1 });
196
+ expect(exhausted['status']).toBe(500);
197
+
198
+ missingHits = 0;
199
+ const notRetried = await callUdf({ url: `${baseUrl}/missing`, retry: 3 });
200
+ expect(notRetried['status']).toBe(404);
201
+ expect(missingHits).toBe(1);
202
+ });
203
+
204
+ test('非法 URL 直接报错且不重试', async () => {
205
+ const result = await callUdf({ url: 'not-a-url', retry: 2 });
206
+ expect(result['status']).toBe(0);
207
+ expect(String(result['error'])).toContain('invalid url');
208
+ });
209
+
210
+ test('引擎路径:funcBindParams 按声明顺序位置绑定,url 缺省参数回退默认值', async () => {
211
+ const kwargs = globalUdfRegistry.funcBindParams('http_request', [`${baseUrl}/json`]);
212
+ expect(Object.keys(kwargs)).toEqual(['url', 'method', 'headers', 'body', 'params', 'timeout', 'retry', 'auth']);
213
+ expect(kwargs['method']).toBe('GET');
214
+ expect(kwargs['timeout']).toBe(10000);
215
+ expect(kwargs['retry']).toBe(0);
216
+ const result = (await globalUdfRegistry.call('http_request', kwargs)) as Record<string, unknown>;
217
+ expect(result['status']).toBe(200);
218
+ expect(result['body']).toEqual({ ok: true, q: null });
219
+ });
220
+
221
+ test('旧图兼容:仅前 4 个位置参数时新参数回退默认值并可正常执行', async () => {
222
+ const kwargs = globalUdfRegistry.funcBindParams('http_request', [`${baseUrl}/json`, 'POST', { x: '1' }, { a: 1 }]);
223
+ expect(kwargs['params']).toEqual({});
224
+ expect(kwargs['auth']).toEqual({});
225
+ expect(kwargs['timeout']).toBe(10000);
226
+ expect(kwargs['retry']).toBe(0);
227
+ const result = (await globalUdfRegistry.call('http_request', kwargs)) as Record<string, unknown>;
228
+ expect(result['status']).toBe(200);
229
+ });
230
+ });
@@ -0,0 +1,283 @@
1
+ // http 域(http_request 函数,有专属 UI 设计,文件名即 namespace)
2
+ import { getExecContext } from '../exec-context.ts';
3
+ import { defineContrib, defineTool } from '../register.ts';
4
+
5
+ /**
6
+ * 出口防护端口(执行规范 §6.3,U9):按租户校验出口 URL,拒绝时抛错。
7
+ * verdict 注入真实 allowlist;未配置 = 允许所有出口(开发态默认)。
8
+ */
9
+ export interface EgressGuard {
10
+ assertAllowed(url: string, tenantId: string | undefined): void | Promise<void>;
11
+ }
12
+
13
+ /** 密钥解析端口:图内 auth 值支持 `${secret:名称}` 引用,真实凭证按租户解析,不进图内容 */
14
+ export interface SecretResolver {
15
+ resolve(ref: string, tenantId: string | undefined): string | Promise<string>;
16
+ }
17
+
18
+ let egressGuard: EgressGuard | undefined;
19
+ let secretResolver: SecretResolver | undefined;
20
+
21
+ export const configureHttpUdf = (options: { egressGuard?: EgressGuard; secretResolver?: SecretResolver }): void => {
22
+ egressGuard = options.egressGuard;
23
+ secretResolver = options.secretResolver;
24
+ };
25
+
26
+ const SECRET_REF_PATTERN = /^\$\{secret:([^}]+)\}$/;
27
+
28
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
29
+ const DEFAULT_TIMEOUT_MS = 10_000;
30
+ const MIN_TIMEOUT_MS = 100;
31
+ const MAX_TIMEOUT_MS = 60_000;
32
+ const MAX_RETRIES = 5;
33
+ const RETRY_BASE_DELAY_MS = 200;
34
+
35
+ const asRecord = (value: unknown): Record<string, unknown> =>
36
+ value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
37
+
38
+ const httpErrorResult = (error: string) => ({ status: 0, headers: {}, body: null, error });
39
+
40
+ /** 宽松整数化:null/undefined/空串/非法值回退 fallback,并夹取 [min, max] */
41
+ const coerceCount = (value: unknown, min: number, max: number, fallback: number): number => {
42
+ if (value === null || value === undefined || value === '') {
43
+ return fallback;
44
+ }
45
+ const n = Math.floor(Number(value));
46
+ if (!Number.isFinite(n)) {
47
+ return fallback;
48
+ }
49
+ return Math.min(max, Math.max(min, n));
50
+ };
51
+
52
+ /** 查询参数合并:URL 解析失败返回 null(由调用方直接报错,不参与重试) */
53
+ const buildUrlWithParams = (rawUrl: string, params: Record<string, unknown>): string | null => {
54
+ let parsed: URL;
55
+ try {
56
+ parsed = new URL(rawUrl);
57
+ } catch {
58
+ return null;
59
+ }
60
+ for (const [key, value] of Object.entries(params)) {
61
+ parsed.searchParams.set(key, String(value));
62
+ }
63
+ return parsed.toString();
64
+ };
65
+
66
+ /** 认证注入:headers 显式 Authorization 优先;basic 编码 user:password,bearer 直填 token */
67
+ const applyAuthHeader = (requestHeaders: Record<string, string>, auth: Record<string, unknown>): void => {
68
+ const hasAuthorization = Object.keys(requestHeaders).some((k) => k.toLowerCase() === 'authorization');
69
+ if (hasAuthorization) {
70
+ return;
71
+ }
72
+ const type = String(auth.type ?? '')
73
+ .trim()
74
+ .toLowerCase();
75
+ if (type === 'basic') {
76
+ const raw = `${String(auth.username ?? '')}:${String(auth.password ?? '')}`;
77
+ const encoded = Buffer.from(raw, 'utf8').toString('base64');
78
+ requestHeaders['authorization'] = `Basic ${encoded}`;
79
+ } else if (type === 'bearer') {
80
+ const token = String(auth.token ?? '');
81
+ if (token) {
82
+ requestHeaders['authorization'] = `Bearer ${token}`;
83
+ }
84
+ }
85
+ };
86
+
87
+ interface HttpAttemptResult {
88
+ status: number;
89
+ headers: Record<string, string>;
90
+ body: unknown;
91
+ error?: undefined | string;
92
+ /** 策略性失败(egress/secret):不参与重试 */
93
+ policyBlocked?: boolean;
94
+ }
95
+
96
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
97
+
98
+ /** 网络异常/超时(status=0)、429 与 5xx 可重试;其余 4xx 属业务错误不重试;策略性失败不重试 */
99
+ const shouldRetryResult = (result: HttpAttemptResult): boolean =>
100
+ !result.policyBlocked && (result.status === 0 || result.status === 429 || result.status >= 500);
101
+
102
+ export const http_request = defineTool({
103
+ name: 'http_request',
104
+ description:
105
+ '发起 HTTP 请求, 返回响应结果 { status, headers, body }. 支持 params 查询参数合并、timeout 单次超时(默认 10s, 上限 60s)、' +
106
+ 'retry 重试(仅网络异常/超时/5xx/429, 指数退避)与 auth 认证({ type: "basic", username, password } 或 { type: "bearer", token }, ' +
107
+ 'headers 显式 Authorization 优先). 失败返回结构化错误 { status: 0, error }, 不抛出异常.',
108
+ parametersSchema: {
109
+ properties: {
110
+ url: {
111
+ type: 'string',
112
+ title: 'URL',
113
+ description: '请求地址',
114
+ },
115
+ method: {
116
+ type: 'string',
117
+ title: 'Method',
118
+ description: 'HTTP 方法(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS),默认 GET',
119
+ default: 'GET',
120
+ },
121
+ headers: {
122
+ type: 'object',
123
+ title: 'Headers',
124
+ description: '请求头键值对对象,默认无',
125
+ default: null,
126
+ },
127
+ body: {
128
+ type: 'object',
129
+ title: 'Body',
130
+ description:
131
+ '请求体对象(自动 JSON 序列化并补充 content-type: application/json),GET/HEAD 忽略,空对象视为无请求体',
132
+ default: null,
133
+ },
134
+ params: {
135
+ type: 'object',
136
+ title: 'Params',
137
+ description: '查询参数键值对对象,合并到 URL 查询串(URL 已有同名参数时覆盖),默认无',
138
+ default: null,
139
+ },
140
+ timeout: {
141
+ type: 'integer',
142
+ title: 'Timeout',
143
+ description: `单次请求超时毫秒数(${MIN_TIMEOUT_MS}–${MAX_TIMEOUT_MS}),默认 ${DEFAULT_TIMEOUT_MS}`,
144
+ default: DEFAULT_TIMEOUT_MS,
145
+ },
146
+ retry: {
147
+ type: 'integer',
148
+ title: 'Retry',
149
+ description: `失败重试次数(0–${MAX_RETRIES}),仅网络异常/超时/5xx/429 触发,指数退避,默认 0`,
150
+ default: 0,
151
+ },
152
+ auth: {
153
+ type: 'object',
154
+ title: 'Auth',
155
+ description:
156
+ "认证配置。Basic: { type: 'basic', username, password };Bearer: { type: 'bearer', token }。headers 显式 Authorization 优先,默认无",
157
+ default: null,
158
+ },
159
+ },
160
+ required: ['url'],
161
+ title: 'http_request',
162
+ type: 'object',
163
+ },
164
+ returnsSchema: { type: 'object', title: 'http_request 函数返回', properties: {} },
165
+ fn: async function httpRequestUdf(kwargs: Record<string, unknown>) {
166
+ const rawUrl = String(kwargs?.url ?? '').trim();
167
+ const method =
168
+ String(kwargs?.method ?? 'GET')
169
+ .trim()
170
+ .toUpperCase() || 'GET';
171
+ const rawBody = asRecord(kwargs?.body);
172
+ const rawParams = asRecord(kwargs?.params);
173
+ const rawAuth = asRecord(kwargs?.auth);
174
+ const retryCount = coerceCount(kwargs?.retry, 0, MAX_RETRIES, 0);
175
+
176
+ if (!rawUrl) {
177
+ return httpErrorResult('url is required');
178
+ }
179
+ if (!HTTP_METHODS.has(method)) {
180
+ return httpErrorResult(`unsupported http method '${method}'`);
181
+ }
182
+
183
+ const url = buildUrlWithParams(rawUrl, rawParams);
184
+ if (!url) {
185
+ return httpErrorResult(`invalid url '${rawUrl}'`);
186
+ }
187
+
188
+ // 执行规范 §6.3:出口防护(egress 拒绝属策略性失败,不重试)
189
+ const tenantId = getExecContext()?.tenantId;
190
+ try {
191
+ await egressGuard?.assertAllowed(url, tenantId);
192
+ } catch (e) {
193
+ const reason = e instanceof Error ? e.message : String(e);
194
+ return { ...httpErrorResult('egress blocked by policy: ' + reason), policyBlocked: true };
195
+ }
196
+
197
+ // 执行规范:secret 引用解析(`${secret:名称}` → 按租户解析真实凭证,不进图内容)
198
+ const effectiveAuth = { ...rawAuth };
199
+ try {
200
+ if (secretResolver) {
201
+ for (const key of ['username', 'password', 'token']) {
202
+ const value = effectiveAuth[key];
203
+ if (typeof value === 'string') {
204
+ const ref = value.match(SECRET_REF_PATTERN);
205
+ if (ref) {
206
+ effectiveAuth[key] = await secretResolver.resolve(ref[1], tenantId);
207
+ }
208
+ }
209
+ }
210
+ } else {
211
+ for (const key of ['username', 'password', 'token']) {
212
+ if (typeof effectiveAuth[key] === 'string' && SECRET_REF_PATTERN.test(effectiveAuth[key])) {
213
+ return {
214
+ ...httpErrorResult(`secret reference in auth.${key} requires a configured secretResolver`),
215
+ policyBlocked: true,
216
+ };
217
+ }
218
+ }
219
+ }
220
+ } catch (e) {
221
+ const reason = e instanceof Error ? e.message : String(e);
222
+ return { ...httpErrorResult('secret resolve failed: ' + reason), policyBlocked: true };
223
+ }
224
+
225
+ const requestHeaders: Record<string, string> = {};
226
+ for (const [key, value] of Object.entries(asRecord(kwargs?.headers))) {
227
+ requestHeaders[String(key)] = String(value);
228
+ }
229
+ applyAuthHeader(requestHeaders, effectiveAuth);
230
+
231
+ let requestBody: string | undefined;
232
+ if (method !== 'GET' && method !== 'HEAD' && Object.keys(rawBody).length > 0) {
233
+ requestBody = JSON.stringify(rawBody);
234
+ const hasContentType = Object.keys(requestHeaders).some((k) => k.toLowerCase() === 'content-type');
235
+ if (!hasContentType) {
236
+ requestHeaders['content-type'] = 'application/json';
237
+ }
238
+ }
239
+
240
+ const attemptOnce = async (): Promise<HttpAttemptResult> => {
241
+ const controller = new AbortController();
242
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
243
+ try {
244
+ const response = await fetch(url, {
245
+ method,
246
+ headers: requestHeaders,
247
+ body: requestBody,
248
+ signal: controller.signal,
249
+ });
250
+ const responseText = await response.text();
251
+ let responseBody: unknown;
252
+ try {
253
+ responseBody = JSON.parse(responseText);
254
+ } catch {
255
+ responseBody = responseText;
256
+ }
257
+ return {
258
+ status: response.status,
259
+ headers: Object.fromEntries(response.headers.entries()),
260
+ body: responseBody,
261
+ };
262
+ } catch (e) {
263
+ return { status: 0, headers: {}, body: null, error: e instanceof Error ? e.message : String(e) };
264
+ } finally {
265
+ clearTimeout(timer);
266
+ }
267
+ };
268
+
269
+ const timeoutMs = coerceCount(kwargs?.timeout, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
270
+ let result = await attemptOnce();
271
+ for (let attempt = 1; attempt <= retryCount && shouldRetryResult(result); attempt += 1) {
272
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
273
+ result = await attemptOnce();
274
+ }
275
+ return result;
276
+ },
277
+ });
278
+
279
+ export const { fn: httpRequest } = http_request;
280
+
281
+ export default defineContrib(import.meta.url, {
282
+ tools: [http_request],
283
+ });
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { defineContrib, defineTool } from '../register.ts';
4
+
5
+ /**
6
+ * 旧平台函数域重建(第六十九批 D2,docs/13 §8.3):IP 属地解析。
7
+ * 数据集**不捆绑**——通过 env `IP_LOCATION_DATASET` 指向 JSON 文件(结构:
8
+ * `{ "<ip前缀>": { "country": "..", "province": "..", "city": "..", "isp": ".." }, ... }`,
9
+ * 最长前缀匹配)。未配置/未命中时返回空字段 + ip 回显(图内 returnSchema 的
10
+ * required 字段齐全,仿真链路可跑通)。
11
+ */
12
+
13
+ interface GeoEntry {
14
+ country?: string;
15
+ province?: string;
16
+ city?: string;
17
+ isp?: string;
18
+ }
19
+
20
+ let cachedPath: string | undefined;
21
+ let cachedDataset: Record<string, GeoEntry> | undefined;
22
+
23
+ const loadDataset = (): Record<string, GeoEntry> => {
24
+ const p = process.env.IP_LOCATION_DATASET;
25
+ if (!p) return {};
26
+ if (cachedPath === p && cachedDataset) return cachedDataset;
27
+ try {
28
+ cachedDataset = JSON.parse(readFileSync(p, 'utf8')) as Record<string, GeoEntry>;
29
+ cachedPath = p;
30
+ } catch (error) {
31
+ console.warn('[ip_location] 数据集加载失败,按未配置处理:', error instanceof Error ? error.message : error);
32
+ cachedDataset = {};
33
+ cachedPath = p;
34
+ }
35
+ return cachedDataset;
36
+ };
37
+
38
+ export default defineContrib(import.meta.url, {
39
+ tools: [
40
+ defineTool({
41
+ name: 'ip_location',
42
+ description:
43
+ '旧域重建·IP 属地解析:按最长前缀匹配数据集(env IP_LOCATION_DATASET 指向 JSON)返回 country/province/city/isp;未命中返回空字段。',
44
+ parametersSchema: {
45
+ properties: {
46
+ ip: {
47
+ type: 'string',
48
+ title: 'IP',
49
+ description: '待解析的 IP 地址',
50
+ },
51
+ },
52
+ },
53
+ returnsSchema: {
54
+ type: 'object',
55
+ title: 'ip_location_result',
56
+ properties: {
57
+ country: { type: 'string', title: 'Country' },
58
+ province: { type: 'string', title: 'Province' },
59
+ city: { type: 'string', title: 'City' },
60
+ isp: { type: 'string', title: 'Isp' },
61
+ ip: { type: 'string', title: 'Ip' },
62
+ },
63
+ required: ['country', 'province', 'city', 'isp', 'ip'],
64
+ },
65
+ fn: function ipLocationUdf(kwargs: Record<string, unknown>) {
66
+ const ip = String(kwargs?.ip ?? '');
67
+ const dataset = loadDataset();
68
+ const hit = Object.keys(dataset)
69
+ .filter((prefix) => ip.startsWith(prefix))
70
+ .sort((a, b) => b.length - a.length)[0];
71
+ const geo = hit ? dataset[hit] : undefined;
72
+ return {
73
+ country: geo?.country ?? '',
74
+ province: geo?.province ?? '',
75
+ city: geo?.city ?? '',
76
+ isp: geo?.isp ?? '',
77
+ ip,
78
+ };
79
+ },
80
+ }),
81
+ ],
82
+ });
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, test } from 'vitest';
4
+
5
+ import { DecisionRuntime } from '../engine.ts';
6
+ import { runWithExecContext } from '../exec-context.ts';
7
+ import { deleteRoster, registerRoster } from '../roster.ts';
8
+ import './custom-list-query.ts';
9
+ import './ip-location.ts';
10
+ import { __resetRateWindows } from './rate-window.ts';
11
+ import './rate-window.ts';
12
+
13
+ /**
14
+ * 第六十九批 D2 闭环验收:撞库攻击防御.json 仿真恢复。
15
+ * 该图 4 个 UDF 节点依赖 custom_list_query / ip_location / rate_1h / group_distinct_1h
16
+ * (docs/13 §7.3:此前验收降级为加载/渲染/编辑,仿真因 udf not found 暂缓)。
17
+ */
18
+ const GRAPH = path.resolve(import.meta.dirname, '../../graph/撞库攻击防御.json');
19
+ const LIST_NAME = '熊猫ip白名单';
20
+ const ACTOR = 'acceptance-user';
21
+
22
+ describe('撞库攻击防御.json 仿真验收(第六十九批 D2 闭环)', () => {
23
+ test('白名单命中路径:无 udf not found,custom_list_query result:true', async () => {
24
+ __resetRateWindows();
25
+ registerRoster({ name: LIST_NAME, items: ['8.8.8.8'] }, { tenantId: 'acceptance-tenant', actor: ACTOR });
26
+ const content = JSON.parse(readFileSync(GRAPH, 'utf8')) as unknown;
27
+ const zr = new DecisionRuntime({});
28
+ await runWithExecContext({ tenantId: 'acceptance-tenant', userId: ACTOR }, async () =>
29
+ zr.createDecisionWithCacheKey('chuangku-hit', JSON.stringify(content)),
30
+ );
31
+
32
+ const result = (await runWithExecContext({ tenantId: 'acceptance-tenant', userId: ACTOR }, () =>
33
+ zr.evaluateAsync('chuangku-hit', { ip: '8.8.8.8', phone: '13800000000' }, { trace: true }),
34
+ )) as { result?: { reason?: string }; performance?: string };
35
+ const s = JSON.stringify(result);
36
+
37
+ expect(s).not.toContain('udf not found');
38
+ // 命中语义:最终 reason 由白名单查询结果驱动("ip白名单,通过")
39
+ expect(result.result?.reason ?? '').toContain('白名单');
40
+ // custom_list_query 输出 {result:true} 进入 trace
41
+ expect(s).toContain('"result":true');
42
+ deleteRoster(LIST_NAME, { tenantId: 'acceptance-tenant', actor: ACTOR });
43
+ });
44
+
45
+ test('非白名单路径:频控/组去重/属地函数全部解析执行', async () => {
46
+ __resetRateWindows();
47
+ registerRoster({ name: LIST_NAME, items: ['8.8.8.8'] }, { tenantId: 'acceptance-tenant', actor: ACTOR });
48
+ const content = JSON.parse(readFileSync(GRAPH, 'utf8')) as unknown;
49
+ const zr = new DecisionRuntime({});
50
+ await runWithExecContext({ tenantId: 'acceptance-tenant', userId: ACTOR }, async () =>
51
+ zr.createDecisionWithCacheKey('chuangku-miss', JSON.stringify(content)),
52
+ );
53
+
54
+ const result = (await runWithExecContext({ tenantId: 'acceptance-tenant', userId: ACTOR }, () =>
55
+ zr.evaluateAsync('chuangku-miss', { ip: '9.9.9.9', phone: '13900000000' }, { trace: true }),
56
+ )) as unknown as Record<string, unknown>;
57
+ const s = JSON.stringify(result);
58
+
59
+ expect(s).not.toContain('udf not found');
60
+ // 指标计算节点(非白名单路径)的 UDF 输出进入 trace:rate 计数与组去重 pv
61
+ expect(s).toContain('"counter":1');
62
+ expect(s).toContain('"pv":1');
63
+ // ip_location 空 dataset 回退:字段齐全 + ip 回显
64
+ expect(s).toContain('"ip":"9.9.9.9"');
65
+ deleteRoster(LIST_NAME, { tenantId: 'acceptance-tenant', actor: ACTOR });
66
+ });
67
+ });