bazis 0.134.0 → 0.136.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 (38) hide show
  1. package/core/access/contracts/References.ts +2 -0
  2. package/core/agent/AgentToolExecutor.ts +3 -0
  3. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  4. package/core/tools/StandardTools.module.ts +10 -2
  5. package/core/tools/contracts/Tools.config.ts +30 -0
  6. package/core/tools/generated/deps.ts +5 -0
  7. package/core/tools/http/HttpRequest.tool.ts +246 -0
  8. package/core/tools/http/publicAddress.ts +94 -0
  9. package/core/tools/index.ts +5 -2
  10. package/core/tools/orm/OrmEntity.references.ts +11 -7
  11. package/core/tools/secrets/SecretStore.ts +28 -0
  12. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
  13. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
  14. package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
  15. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +23 -0
  16. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +56 -6
  17. package/core/workflow/generated/deps.ts +5 -2
  18. package/core/workflow/internal/WorkflowReferences.ts +69 -0
  19. package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
  20. package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
  21. package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
  22. package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
  23. package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
  24. package/core/workflow/runtime_module/internal/RunLimits.ts +4 -3
  25. package/core/workflow/runtime_module/internal/ToolStep.ts +45 -5
  26. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +20 -7
  27. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +13 -0
  28. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +11 -1
  29. package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
  30. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
  31. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
  32. package/library/redaction/index.ts +28 -0
  33. package/library/workflow/expressions.ts +8 -1
  34. package/library/workflow/index.ts +2 -1
  35. package/library/workflow/paths.ts +21 -1
  36. package/library/workflow/references.ts +106 -0
  37. package/library/workflow/validateProcess.ts +6 -5
  38. package/package.json +1 -1
@@ -25,6 +25,8 @@ export interface ReferenceItem {
25
25
  export interface ReferenceContext {
26
26
  readonly scope: Scope;
27
27
  readonly language?: string;
28
+ /** The area the ids are looked for in: the fields of one table. A kind without areas ignores it. */
29
+ readonly within?: string;
28
30
  }
29
31
 
30
32
  export interface ReferencePage {
@@ -62,6 +62,8 @@ export interface AgentToolExecutionContext {
62
62
  readonly actor?: ToolActor;
63
63
  /** A trial: the Tool, one with `dryRun: "supported"`, changes nothing (WORKFLOW-002 §5.4). */
64
64
  readonly dryRun?: boolean;
65
+ /** Who calls: `"agent"` or the caller id of `ToolRunner.as`. */
66
+ readonly caller?: string;
65
67
  readonly metadata: JsonObject;
66
68
  readonly signal: AbortSignal;
67
69
  }
@@ -1672,6 +1674,7 @@ export class AgentToolExecutor {
1672
1674
  approval,
1673
1675
  ...(options.actor !== undefined ? { actor: options.actor } : {}),
1674
1676
  ...(options.dryRun === true ? { dryRun: true } : {}),
1677
+ ...(callerOf(options) === undefined ? {} : { caller: callerOf(options)! }),
1675
1678
  metadata,
1676
1679
  signal,
1677
1680
  });
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.134.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.136.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -1,14 +1,22 @@
1
1
  import { AccessModule } from "../access";
2
2
  import { Module, singleton } from "../di";
3
+ import { toolsConfig } from "./contracts/Tools.config";
4
+ import { HttpRequestTool } from "./http/HttpRequest.tool";
3
5
  import { OrmEntityReferences, OrmFieldReferences } from "./orm/OrmEntity.references";
6
+ import { ConfigSecretStore, SecretStore } from "./secrets/SecretStore";
4
7
 
5
8
  /**
6
9
  * Tools that come with the framework (WORKFLOW-002 §7): not needed until an
7
- * application imports it. For now it owns the reference kinds of `ormEntityTools`.
10
+ * application imports it. Here are `http.request`, closed until `tools.http.allow`
11
+ * lists a host, the values of `$secrets` and the reference kinds of `ormEntityTools`.
12
+ * The application replaces the secrets by registering its `SecretStore`.
8
13
  */
9
14
  @Module({
10
15
  imports: [AccessModule],
16
+ config: [toolsConfig],
11
17
  references: { "orm.entity": OrmEntityReferences, "orm.field": OrmFieldReferences },
12
- providers: [singleton(OrmEntityReferences), singleton(OrmFieldReferences)],
18
+ tools: [HttpRequestTool],
19
+ providers: [singleton(OrmEntityReferences), singleton(OrmFieldReferences), singleton(SecretStore, ConfigSecretStore)],
20
+ exports: [SecretStore],
13
21
  })
14
22
  export class StandardToolsModule {}
@@ -0,0 +1,30 @@
1
+ import { defineConfig } from "../../kernel";
2
+
3
+ /**
4
+ * Settings of the standard Tools. `http.allow` is the comma-separated list of hosts
5
+ * `http.request` may reach: `api.example.com`, `*.example.com`, `localhost:8080`.
6
+ * Empty by default: requests go nowhere. `http.allowPrivateNetworks` lets it reach
7
+ * private and loopback addresses of a listed host: intranet services, tests.
8
+ * `http.callers` says who may call it: `workflow`, `agent`.
9
+ */
10
+ export interface ToolsSettings {
11
+ readonly "http.allow": string;
12
+ readonly "http.callers": string;
13
+ readonly "http.allowPrivateNetworks": boolean;
14
+ readonly "http.maxResponseBytes": number;
15
+ readonly "http.timeoutMs": number;
16
+ }
17
+
18
+ export const toolsConfig = defineConfig<ToolsSettings>("tools", {
19
+ default: {
20
+ "http.allow": "",
21
+ "http.callers": "workflow",
22
+ "http.allowPrivateNetworks": false,
23
+ "http.maxResponseBytes": 1_048_576,
24
+ "http.timeoutMs": 30_000,
25
+ },
26
+ validate: {
27
+ "http.maxResponseBytes": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 104_857_600 ? undefined : "must be an integer 1–104857600"),
28
+ "http.timeoutMs": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 600_000 ? undefined : "must be an integer 1–600000"),
29
+ },
30
+ });
@@ -2,9 +2,14 @@
2
2
  // Do not edit manually.
3
3
  import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
4
  import { ServiceProvider } from "../../di/ServiceProvider";
5
+ import { Configuration } from "../../kernel/config/Configuration";
6
+ import { HttpRequestTool } from "../http/HttpRequest.tool";
5
7
  import { OrmEntityReferences, OrmFieldReferences } from "../orm/OrmEntity.references";
8
+ import { ConfigSecretStore } from "../secrets/SecretStore";
6
9
 
7
10
  registerFrameworkClassDeps([
11
+ [ConfigSecretStore, [Configuration]],
12
+ [HttpRequestTool, ["Config:tools"]],
8
13
  [OrmEntityReferences, [ServiceProvider]],
9
14
  [OrmFieldReferences, [ServiceProvider]],
10
15
  ]);
@@ -0,0 +1,246 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { Tool, ToolError, type AgentToolExecutionContext } from "../../agent";
4
+ import type { ConfigView } from "../../kernel";
5
+ import type { ToolsSettings } from "../contracts/Tools.config";
6
+ import { hostAllowed, isPublicAddress } from "./publicAddress";
7
+
8
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
9
+ const SAFE_METHODS = new Set(["GET", "HEAD"]);
10
+ const MAX_REDIRECTS = 5;
11
+ /** What a redirect to another host must not carry. */
12
+ const CREDENTIALS = new Set(["authorization", "cookie", "proxy-authorization"]);
13
+
14
+ const secretText = { type: "string", description: "Usually $secrets.NAME" };
15
+
16
+ const INPUT_SCHEMA = {
17
+ type: "object",
18
+ properties: {
19
+ method: { enum: [...METHODS] },
20
+ url: { type: "string", format: "uri" },
21
+ headers: { type: "object", additionalProperties: { type: "string" } },
22
+ body: { description: "Sent as JSON" },
23
+ auth: {
24
+ type: "object",
25
+ description: "none; bearer with token; basic with username and password; apiKey with header and value",
26
+ properties: {
27
+ type: { enum: ["none", "bearer", "basic", "apiKey"] },
28
+ token: secretText,
29
+ username: { type: "string" },
30
+ password: secretText,
31
+ header: { type: "string" },
32
+ value: secretText,
33
+ },
34
+ required: ["type"],
35
+ },
36
+ successCodes: { type: "array", items: { type: "integer", minimum: 100, maximum: 599 } },
37
+ timeoutMs: { type: "integer", minimum: 1 },
38
+ },
39
+ required: ["url"],
40
+ additionalProperties: false,
41
+ };
42
+
43
+ const OUTPUT_SCHEMA = {
44
+ type: "object",
45
+ properties: {
46
+ status: { type: "integer" },
47
+ headers: { type: "object" },
48
+ body: { description: "JSON, or text when the response is not JSON" },
49
+ dryRun: { type: "boolean" },
50
+ },
51
+ };
52
+
53
+ interface Request {
54
+ readonly method: string;
55
+ readonly url: URL;
56
+ readonly headers: Readonly<Record<string, string>>;
57
+ readonly body: string | undefined;
58
+ readonly successCodes: ReadonlySet<number> | undefined;
59
+ readonly timeoutMs: number;
60
+ /** The header `auth` of type `apiKey` puts the key in: a redirect to another host drops it. */
61
+ readonly apiKeyHeader: string | undefined;
62
+ }
63
+
64
+ /**
65
+ * A request to the outside (WORKFLOW-002 §7.1), closed by default: an empty
66
+ * `tools.http.allow` lets nothing through. A host must be listed, and every address
67
+ * it resolves to must be public, on each redirect too; `http` goes to the address
68
+ * that was checked, `https` is held to its certificate. A status outside the success
69
+ * codes is an error with the reason `http_<status>`; 5xx and 429 are plain failures,
70
+ * so `retry` of the step repeats them. A trial makes only `GET` and `HEAD`.
71
+ */
72
+ @Tool({
73
+ name: "http.request",
74
+ description: "Makes an HTTP request to an allowed host.",
75
+ sideEffect: "external",
76
+ dryRun: "supported",
77
+ callers: ["workflow", "agent"],
78
+ inputSchema: INPUT_SCHEMA,
79
+ outputSchema: OUTPUT_SCHEMA,
80
+ })
81
+ export class HttpRequestTool {
82
+ constructor(private readonly config: ConfigView<ToolsSettings>) {}
83
+
84
+ async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
85
+ const callers = this.config.get("http.callers").split(",").map((item) => item.trim());
86
+ if (context.caller !== undefined && !callers.includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
87
+ const request = this.requestOf(raw);
88
+ if (context.dryRun === true && !SAFE_METHODS.has(request.method)) return { dryRun: true };
89
+ const signal = AbortSignal.any([context.signal, AbortSignal.timeout(request.timeoutMs)]);
90
+ const response = await this.follow(request, signal);
91
+ const body = await this.bodyOf(response, request.method, signal);
92
+ const allowed = request.successCodes === undefined ? response.ok : request.successCodes.has(response.status);
93
+ if (!allowed) {
94
+ const message = `${request.method} ${request.url.host} answered ${response.status}`;
95
+ if (response.status >= 500 || response.status === 429) throw new Error(message);
96
+ throw new ToolError(message, { reason: `http_${response.status}` });
97
+ }
98
+ const headers = Object.fromEntries([...response.headers].filter(([name]) => name !== "set-cookie"));
99
+ return { status: response.status, headers, body, ...(context.dryRun === true ? { dryRun: true } : {}) };
100
+ }
101
+
102
+ private requestOf(raw: unknown): Request {
103
+ const input = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
104
+ const method = String(input.method ?? "GET").toUpperCase();
105
+ if (!METHODS.has(method)) throw invalid(`method must be one of ${[...METHODS].join(", ")}`);
106
+ if (typeof input.url !== "string") throw invalid("url is required");
107
+ let url: URL;
108
+ try {
109
+ url = new URL(input.url);
110
+ } catch {
111
+ throw invalid("url is not a URL");
112
+ }
113
+ const headers: Record<string, string> = {};
114
+ if (input.headers !== undefined && input.headers !== null) {
115
+ if (typeof input.headers !== "object" || Array.isArray(input.headers)) throw invalid("headers must be an object");
116
+ for (const [name, value] of Object.entries(input.headers)) {
117
+ if (typeof value !== "string") throw invalid(`header ${name} must be a string`);
118
+ headers[name.toLowerCase()] = value;
119
+ }
120
+ }
121
+ Object.assign(headers, authHeaders(input.auth));
122
+ let body: string | undefined;
123
+ if (input.body !== undefined && input.body !== null) {
124
+ if (SAFE_METHODS.has(method)) throw invalid(`${method} has no body`);
125
+ body = JSON.stringify(input.body);
126
+ headers["content-type"] ??= "application/json";
127
+ }
128
+ let successCodes: Set<number> | undefined;
129
+ if (input.successCodes !== undefined && input.successCodes !== null) {
130
+ if (!Array.isArray(input.successCodes) || input.successCodes.length === 0 || !input.successCodes.every((code) => Number.isInteger(code) && code >= 100 && code <= 599)) throw invalid("successCodes must be a list of status codes");
131
+ successCodes = new Set(input.successCodes as number[]);
132
+ }
133
+ const limit = this.config.get("http.timeoutMs");
134
+ const asked = input.timeoutMs ?? limit;
135
+ if (!Number.isSafeInteger(asked) || (asked as number) < 1) throw invalid("timeoutMs must be a positive integer");
136
+ const apiKeyHeader = (input.auth as { type?: unknown; header?: unknown } | null | undefined)?.type === "apiKey" ? String((input.auth as { header: unknown }).header).toLowerCase() : undefined;
137
+ return { method, url, headers, body, successCodes, timeoutMs: Math.min(asked as number, limit), apiKeyHeader };
138
+ }
139
+
140
+ /** The request and its redirects, each hop checked as the first one. */
141
+ private async follow(first: Request, signal: AbortSignal): Promise<Response> {
142
+ let { method, url, body } = first;
143
+ let headers: Record<string, string> = { ...first.headers };
144
+ const origin = first.url.host;
145
+ const apiKeyHeader = first.apiKeyHeader;
146
+ for (let hop = 0; ; hop += 1) {
147
+ const target = await this.checked(url);
148
+ const response = await fetch(target, {
149
+ method,
150
+ headers: target.href === url.href ? headers : { ...headers, host: url.host },
151
+ redirect: "manual",
152
+ signal,
153
+ ...(body === undefined ? {} : { body }),
154
+ });
155
+ const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
156
+ if (location === null) return response;
157
+ await response.body?.cancel();
158
+ if (hop >= MAX_REDIRECTS) throw new ToolError(`More than ${MAX_REDIRECTS} redirects`, { reason: "too_many_redirects" });
159
+ url = new URL(location, url);
160
+ if (url.host !== origin) headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIALS.has(name) && name !== apiKeyHeader));
161
+ // 301, 302 and 303 turn a write into a read, as browsers do.
162
+ if (response.status !== 307 && response.status !== 308 && !SAFE_METHODS.has(method)) {
163
+ method = "GET";
164
+ body = undefined;
165
+ delete headers["content-type"];
166
+ }
167
+ }
168
+ }
169
+
170
+ /** The URL to connect to, once the host is allowed and its addresses are public. Refused otherwise. */
171
+ private async checked(url: URL): Promise<URL> {
172
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw invalid("only http and https are supported");
173
+ if (url.username !== "" || url.password !== "") throw invalid("credentials go to auth, not to the url");
174
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
175
+ const port = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
176
+ if (!hostAllowed(this.config.get("http.allow"), hostname, port)) throw new ToolError(`tools.http.allow does not list ${url.host}`, { reason: "host_not_allowed" });
177
+ if (this.config.get("http.allowPrivateNetworks")) return url;
178
+ const addresses = isIP(hostname) !== 0 ? [hostname] : (await lookup(hostname, { all: true })).map((item) => item.address);
179
+ if (addresses.length === 0 || !addresses.every(isPublicAddress)) throw new ToolError(`${url.host} is not a public address`, { reason: "address_not_allowed" });
180
+ if (url.protocol === "https:" || isIP(hostname) !== 0) return url;
181
+ // Past this check the name could resolve elsewhere: plain http goes to the address that passed it.
182
+ const pinned = new URL(url);
183
+ const address = addresses[0]!;
184
+ pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
185
+ return pinned;
186
+ }
187
+
188
+ /** The body within `http.maxResponseBytes`: JSON when it is JSON. */
189
+ private async bodyOf(response: Response, method: string, signal: AbortSignal): Promise<unknown> {
190
+ if (method === "HEAD" || response.body === null) return null;
191
+ const limit = this.config.get("http.maxResponseBytes");
192
+ const tooLarge = () => new ToolError(`The response is larger than ${limit} bytes`, { reason: "response_too_large" });
193
+ if (Number(response.headers.get("content-length") ?? 0) > limit) {
194
+ await response.body.cancel();
195
+ throw tooLarge();
196
+ }
197
+ const chunks: Uint8Array[] = [];
198
+ let size = 0;
199
+ const reader = response.body.getReader();
200
+ for (;;) {
201
+ signal.throwIfAborted();
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+ size += value.byteLength;
205
+ if (size > limit) {
206
+ await reader.cancel();
207
+ throw tooLarge();
208
+ }
209
+ chunks.push(value);
210
+ }
211
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
212
+ if (text === "") return null;
213
+ if (!/[/+]json\b/i.test(response.headers.get("content-type") ?? "")) return text;
214
+ try {
215
+ return JSON.parse(text) as unknown;
216
+ } catch {
217
+ return text;
218
+ }
219
+ }
220
+ }
221
+
222
+ function invalid(message: string): ToolError {
223
+ return new ToolError(message, { reason: "invalid_input" });
224
+ }
225
+
226
+ function authHeaders(auth: unknown): Record<string, string> {
227
+ if (auth === undefined || auth === null) return {};
228
+ if (typeof auth !== "object" || Array.isArray(auth)) throw invalid("auth must be an object");
229
+ const { type, token, username, password, header, value } = auth as Record<string, unknown>;
230
+ const text = (item: unknown, name: string): string => {
231
+ if (typeof item !== "string" || item === "") throw invalid(`auth.${name} is required for ${String(type)}`);
232
+ return item;
233
+ };
234
+ switch (type) {
235
+ case "none":
236
+ return {};
237
+ case "bearer":
238
+ return { authorization: `Bearer ${text(token, "token")}` };
239
+ case "basic":
240
+ return { authorization: `Basic ${Buffer.from(`${text(username, "username")}:${text(password, "password")}`).toString("base64")}` };
241
+ case "apiKey":
242
+ return { [text(header, "header").toLowerCase()]: text(value, "value") };
243
+ default:
244
+ throw invalid("auth.type must be none, bearer, basic or apiKey");
245
+ }
246
+ }
@@ -0,0 +1,94 @@
1
+ /** A dotted IPv4 address as a number, or `undefined`. */
2
+ function ipv4(text: string): number | undefined {
3
+ const parts = text.split(".");
4
+ if (parts.length !== 4) return undefined;
5
+ let value = 0;
6
+ for (const part of parts) {
7
+ if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return undefined;
8
+ value = value * 256 + Number(part);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ /** An IPv6 address as eight 16-bit groups, or `undefined`. */
14
+ function ipv6(text: string): number[] | undefined {
15
+ let source = text.split("%")[0]!;
16
+ // A dotted tail: ::ffff:127.0.0.1.
17
+ const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(source);
18
+ if (tail) {
19
+ const value = ipv4(tail[2]!);
20
+ if (value === undefined) return undefined;
21
+ source = `${tail[1]}${Math.floor(value / 65536).toString(16)}:${(value % 65536).toString(16)}`;
22
+ }
23
+ const [head, rest, ...more] = source.split("::");
24
+ if (more.length > 0) return undefined;
25
+ const groups = (part: string | undefined) => (part === undefined || part === "" ? [] : part.split(":"));
26
+ const left = groups(head);
27
+ const right = groups(rest);
28
+ const missing = 8 - left.length - right.length;
29
+ if (rest === undefined ? missing !== 0 : missing < 1) return undefined;
30
+ const all = [...left, ...new Array<string>(rest === undefined ? 0 : missing).fill("0"), ...right];
31
+ return all.every((group) => /^[0-9a-f]{1,4}$/i.test(group)) ? all.map((group) => Number.parseInt(group, 16)) : undefined;
32
+ }
33
+
34
+ function inRange(value: number, base: string, bits: number): boolean {
35
+ const size = 2 ** (32 - bits);
36
+ return Math.floor(value / size) === Math.floor(ipv4(base)! / size);
37
+ }
38
+
39
+ const NOT_PUBLIC_V4: readonly (readonly [string, number])[] = [
40
+ ["0.0.0.0", 8],
41
+ ["10.0.0.0", 8],
42
+ ["100.64.0.0", 10],
43
+ ["127.0.0.0", 8],
44
+ ["169.254.0.0", 16],
45
+ ["172.16.0.0", 12],
46
+ ["192.0.0.0", 24],
47
+ ["192.0.2.0", 24],
48
+ ["192.168.0.0", 16],
49
+ ["198.18.0.0", 15],
50
+ ["198.51.100.0", 24],
51
+ ["203.0.113.0", 24],
52
+ ["224.0.0.0", 4],
53
+ ["240.0.0.0", 4],
54
+ ];
55
+
56
+ function isPublicV4(value: number): boolean {
57
+ return !NOT_PUBLIC_V4.some(([base, bits]) => inRange(value, base, bits));
58
+ }
59
+
60
+ /**
61
+ * Whether an address can be reached from the outside: not loopback, private,
62
+ * link-local, multicast or reserved. An IPv6 address that carries an IPv4 one is
63
+ * judged by it. What cannot be read is not public.
64
+ */
65
+ export function isPublicAddress(address: string): boolean {
66
+ const v4 = ipv4(address);
67
+ if (v4 !== undefined) return isPublicV4(v4);
68
+ const v6 = ipv6(address);
69
+ if (v6 === undefined) return false;
70
+ const carried = (high: number, low: number) => isPublicV4(high * 65536 + low);
71
+ // ::, ::1 and the old form ::a.b.c.d.
72
+ if (v6.slice(0, 6).every((group) => group === 0)) return v6[6] !== 0 && carried(v6[6]!, v6[7]!);
73
+ // ::ffff:a.b.c.d.
74
+ if (v6.slice(0, 5).every((group) => group === 0) && v6[5] === 0xffff) return carried(v6[6]!, v6[7]!);
75
+ // 64:ff9b::a.b.c.d.
76
+ if (v6[0] === 0x64 && v6[1] === 0xff9b) return carried(v6[6]!, v6[7]!);
77
+ // 2002:a.b.c.d::.
78
+ if (v6[0] === 0x2002) return carried(v6[1]!, v6[2]!);
79
+ // fc00::/7, fe80::/10, fec0::/10, ff00::/8, 2001:db8::/32.
80
+ if ((v6[0]! & 0xfe00) === 0xfc00 || (v6[0]! & 0xffc0) === 0xfe80 || (v6[0]! & 0xffc0) === 0xfec0 || (v6[0]! & 0xff00) === 0xff00) return false;
81
+ return !(v6[0] === 0x2001 && v6[1] === 0xdb8);
82
+ }
83
+
84
+ /** Whether `tools.http.allow` lets this host and port through: `api.example.com`, `*.example.com`, `localhost:8080`. */
85
+ export function hostAllowed(allow: string, hostname: string, port: string): boolean {
86
+ const host = hostname.toLowerCase().replace(/\.$/, "");
87
+ return allow.split(",").map((item) => item.trim().toLowerCase()).filter((item) => item !== "").some((item) => {
88
+ // An IPv6 address goes in brackets, as in a URL: [::1]:8080.
89
+ const match = /^\[(.+)\](?::(\d+))?$/.exec(item) ?? /^(.*?)(?::(\d+))?$/.exec(item)!;
90
+ const [name, wanted] = [match[1]!, match[2]];
91
+ if (wanted !== undefined && wanted !== port) return false;
92
+ return name.startsWith("*.") ? host.endsWith(name.slice(1)) && host.length > name.length - 1 : host === name;
93
+ });
94
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Tools that come with the framework (WORKFLOW-002 §7): actions over the
3
- * application's entities for processes and agents, closed by default.
2
+ * Tools that come with the framework (WORKFLOW-002 §7): HTTP requests, actions over
3
+ * the application's entities and secrets, for processes and agents, closed by default.
4
4
  */
5
5
  import "./generated/deps";
6
6
 
7
7
  export { StandardToolsModule } from "./StandardTools.module";
8
+ export { toolsConfig, type ToolsSettings } from "./contracts/Tools.config";
9
+ export { HttpRequestTool } from "./http/HttpRequest.tool";
10
+ export { ConfigSecretStore, SecretStore, type SecretContext } from "./secrets/SecretStore";
8
11
  export { ormEntityTools, type OrmEntityOperation, type OrmEntityToolsOptions } from "./orm/ormEntityTools";
@@ -46,7 +46,7 @@ export class OrmEntityReferences extends References {
46
46
  }
47
47
  }
48
48
 
49
- /** Reference kind `orm.field`: a field open to processes, `Order.status`. */
49
+ /** Reference kind `orm.field`: a field open to processes, `Order.status`, or `status` within `Order`. */
50
50
  export class OrmFieldReferences extends References {
51
51
  constructor(private readonly services: ServiceProvider) {
52
52
  super();
@@ -56,16 +56,20 @@ export class OrmFieldReferences extends References {
56
56
  return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
57
57
  }
58
58
 
59
- async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
59
+ async exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>> {
60
60
  const known = new Set(this.ids());
61
- return new Set(ids.filter((id) => known.has(id)));
61
+ const { within } = context;
62
+ return new Set(ids.filter((id) => known.has(within === undefined ? id : `${within}.${id}`)));
62
63
  }
63
64
 
64
- async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
65
- return [...await this.exists(ids)].map((id) => ({ id, title: id }));
65
+ async describe(ids: readonly string[], context: ReferenceContext): Promise<readonly ReferenceItem[]> {
66
+ return [...await this.exists(ids, context)].map((id) => ({ id, title: id }));
66
67
  }
67
68
 
68
- async search(text: string, _context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
69
- return page(this.ids(), text, range);
69
+ async search(text: string, context: ReferenceContext, range: ReferencePage): Promise<readonly ReferenceItem[]> {
70
+ const { within } = context;
71
+ if (within === undefined) return page(this.ids(), text, range);
72
+ const prefix = `${within}.`;
73
+ return page(this.ids().filter((id) => id.startsWith(prefix)).map((id) => id.slice(prefix.length)), text, range);
70
74
  }
71
75
  }
@@ -0,0 +1,28 @@
1
+ import type { Configuration } from "../../kernel";
2
+
3
+ /** Whose secret is asked for. */
4
+ export interface SecretContext {
5
+ /** The partition of the instance: a tenant may have secrets of its own. */
6
+ readonly partition: string | null;
7
+ }
8
+
9
+ /**
10
+ * Values of `$secrets.NAME` (WORKFLOW-002 §7.2): a document holds only the name.
11
+ * The application registers its implementation in any module; by default the
12
+ * values come from the configuration.
13
+ */
14
+ export abstract class SecretStore {
15
+ /** The value, or `undefined` when there is no such secret. */
16
+ abstract get(name: string, context: SecretContext): string | undefined | Promise<string | undefined>;
17
+ }
18
+
19
+ /** `secrets.<name>` of the configuration: `BAZIS_SECRETS__API_TOKEN` gives `$secrets.API_TOKEN`. */
20
+ export class ConfigSecretStore extends SecretStore {
21
+ constructor(private readonly configuration: Configuration) {
22
+ super();
23
+ }
24
+
25
+ get(name: string): string | undefined {
26
+ return this.configuration.get(`secrets.${name}`);
27
+ }
28
+ }
@@ -6,6 +6,7 @@ import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/Work
6
6
  import { DefaultWorkflowPartitions, WorkflowPartitions } from "../contracts/WorkflowPartitions";
7
7
  import { WorkflowEvents } from "../internal/WorkflowEvents";
8
8
  import { WorkflowLanguages } from "../internal/WorkflowLanguages";
9
+ import { WorkflowReferences } from "../internal/WorkflowReferences";
9
10
  import { WorkflowTools } from "../internal/WorkflowTools";
10
11
  import { definitionsPermissions } from "./access/Definitions.permissions";
11
12
  import { FolderReferences } from "./access/Folder.references";
@@ -42,6 +43,7 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
42
43
  singleton(WorkflowExtensions, DefaultWorkflowExtensions),
43
44
  singleton(WorkflowPartitions, DefaultWorkflowPartitions),
44
45
  scoped(WorkflowEvents),
46
+ scoped(WorkflowReferences),
45
47
  scoped(CatalogImport),
46
48
  scoped(IWorkflowDefinitions, WorkflowDefinitions),
47
49
  scoped(ProcessReferences),
@@ -51,6 +53,6 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
51
53
  singleton(HOSTED_SERVICE, CodeProcessPublisher),
52
54
  ],
53
55
  controllers: [ProcessesController],
54
- exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
56
+ exports: [IWorkflowDefinitions, WorkflowReferences, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
55
57
  })
56
58
  export class WorkflowDefinitionsModule {}
@@ -1,6 +1,7 @@
1
1
  import { markConfigurationError, type ServiceProvider } from "../../../di";
2
2
  import type { HostedService } from "../../../di/extensions/hosted-service";
3
3
  import { LOGGER } from "../../../kernel";
4
+ import { WorkflowReferences } from "../../internal/WorkflowReferences";
4
5
  import { WorkflowTools } from "../../internal/WorkflowTools";
5
6
  import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
6
7
  import { CatalogImport } from "./CatalogImport";
@@ -12,7 +13,9 @@ import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
12
13
  * (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
13
14
  * (WORKFLOW-001 §14). A document with an unknown Tool or process, or an `extensions`
14
15
  * namespace nobody checks, stops the start with every finding of every process; the same
15
- * document again gives no new version.
16
+ * document again gives no new version. So does a reference kind no module owns, in a Tool
17
+ * or a document. A value that does not exist only warns: it may be data that is yet to come,
18
+ * and the step then fails with `ref_not_found`.
16
19
  */
17
20
  export class CodeProcessPublisher implements HostedService {
18
21
  readonly diagnosticName = "workflow:processes";
@@ -25,6 +28,11 @@ export class CodeProcessPublisher implements HostedService {
25
28
  try {
26
29
  const imported = await scope.resolve(CatalogImport).run();
27
30
  if (imported > 0) logger?.info("workflow catalog brought over from the old store", { processes: imported });
31
+ const references = scope.resolve(WorkflowReferences);
32
+ const unowned = references.unownedToolKinds();
33
+ if (unowned.length > 0) {
34
+ throw markConfigurationError(new WorkflowSetupError(`Tools refer to kinds no module owns:\n${unowned.map(({ tool, kind }) => ` - ${tool}: ${kind}${kind.startsWith("orm.") ? " (import StandardToolsModule from bazis/core/tools)" : " (declare it in @Module({ references }))"}`).join("\n")}`));
35
+ }
28
36
  const entries = this.services.resolve(CodeProcessCatalog).list();
29
37
  if (entries.length === 0) return;
30
38
  const tools = this.services.resolve(WorkflowTools);
@@ -35,6 +43,10 @@ export class CodeProcessPublisher implements HostedService {
35
43
  const found = [...definitionProblems(process, (name) => tools.has(name))];
36
44
  for (const issue of await definitions.validate(process.document, { processes })) {
37
45
  if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid" || issue.code === "unknown_process")) found.push(`${issue.message} (${issue.code})`);
46
+ else if (issue.code === "broken_ref") {
47
+ if (issue.message.startsWith("no module owns")) found.push(`${issue.message} (${issue.code})`);
48
+ else logger?.warn("workflow process refers to what does not exist", { process: process.key, node: issue.node, message: issue.message });
49
+ }
38
50
  }
39
51
  if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
40
52
  }
@@ -1,6 +1,6 @@
1
1
  import { expandModuleMetadata, type BazisModuleRef } from "../../../di";
2
- import { toolMetadataOf } from "../../../agent";
3
- import { validateProcess } from "../../../../library/workflow";
2
+ import { describeTool, toolMetadataOf } from "../../../agent";
3
+ import { documentReferences, schemaReferenceKinds, validateProcess } from "../../../../library/workflow";
4
4
  import { WORKFLOW_CALLER } from "../../internal/WorkflowTools";
5
5
  import { WorkflowDefinitionsModule } from "../WorkflowDefinitions.module";
6
6
  import { definitionProblems, type OwnedCodeProcess } from "./codeProcesses";
@@ -28,16 +28,23 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
28
28
  const found: OwnedCodeProcess[] = [];
29
29
  const errors: string[] = [];
30
30
  const tools = new Set<string>();
31
+ const schemas = new Map<string, unknown>();
32
+ const kinds = new Set<string>();
31
33
  const visited = new Set<BazisModuleRef>();
32
34
  const visit = (moduleRef: BazisModuleRef): void => {
33
35
  if (visited.has(moduleRef)) return;
34
36
  visited.add(moduleRef);
35
37
  const module = typeof moduleRef === "function" ? moduleRef.name || "<anonymous module>" : "<module>";
36
- const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown };
38
+ const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown; readonly references?: unknown };
39
+ if (typeof fields.references === "object" && fields.references !== null) for (const kind of Object.keys(fields.references)) kinds.add(kind);
37
40
  if (Array.isArray(fields.tools)) {
38
41
  for (const target of fields.tools) {
39
42
  const metadata = typeof target === "function" ? toolMetadataOf(target as new () => object) : undefined;
40
- if (metadata?.callers.includes(WORKFLOW_CALLER)) tools.add(metadata.name);
43
+ if (metadata?.callers.includes(WORKFLOW_CALLER)) {
44
+ tools.add(metadata.name);
45
+ const input = describeTool({ target: target as new () => object, metadata }).input;
46
+ if (input?.kind === "json-schema") schemas.set(metadata.name, input.schema);
47
+ }
41
48
  }
42
49
  }
43
50
  if (fields.processes !== undefined) {
@@ -54,6 +61,10 @@ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
54
61
  if (found.length > 0 && !visited.has(WorkflowDefinitionsModule)) {
55
62
  errors.push(`${found[0]!.module} declares processes, but the application does not import WorkflowModule (bazis/core/workflow).`);
56
63
  }
64
+ // The values live in the database: `check` knows only whether someone owns their kind.
65
+ for (const [tool, schema] of schemas) {
66
+ for (const kind of schemaReferenceKinds(schema)) if (!kinds.has(kind)) errors.push(`Tool ${tool} refers to the kind ${kind}, and no module owns it.`);
67
+ }
57
68
  const owners = new Map<string, string>();
58
69
  for (const { process, module } of found) {
59
70
  const other = owners.get(process.key);