omp-onlyne 0.0.0-stage → 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.
@@ -0,0 +1,145 @@
1
+ import { createTranslator } from "@onlyne/node-host/i18n";
2
+
3
+ const en = {
4
+ noDecision: "The inbound entry has no decision.",
5
+ noContextNamed: "The inbound decision named no context.",
6
+ contextLost: "{alias} is no longer bound; it needs an explicit new decision",
7
+ noLiveSession: "The execution context {context} has no live session; it must be bound again before it can receive messages.",
8
+ noTaskTool: "This OMP session has no native task tool.",
9
+ noChildIdentity: "The native task tool returned no child identity.",
10
+ deliveryReferences: "message {message}, thread {thread} seq {seq}",
11
+ subagentPreamble: "Read this onlyne delivery from {author} ({references}).",
12
+ subagentCannotSend: "This session cannot send a message. When you have an answer, call the msg tool once to draft it and report the draft id back:",
13
+ subagentDraftCall: 'msg({ delivery_kind: "draft the reply", msg: <your reply body>, meta: { op: "draft", to: {to}, relation: "reply", in_reply_to: { sender: {sender}, id: {id} } } })',
14
+ subagentNoRetry: "That call stores a draft and sends nothing; the main session sends it. Do not retry a refused send and do not poll for the outcome.",
15
+ subagentSolutionSpace: "The delivery decides the work; use the message as the task.",
16
+ labelInbound: "inbound",
17
+ labelSupplement: "supplement",
18
+
19
+ draftNeedsRelation: "A reply draft needs relation reply or followup and in_reply_to.",
20
+ draftChildScope: "A child may draft a reply only to the source message assigned to its context.",
21
+ draftChildCannotSend: "Return draft_id to the main session; a child cannot send a draft.",
22
+ draftNotFound: "No reply draft is named {id}.",
23
+ draftNeedsReference: "A reply draft needs in_reply_to {sender,id} or {thread,seq}.",
24
+ draftSourceUnavailable: "The draft source message is unavailable.",
25
+
26
+ contextNotRegistered: "No execution context is registered as {alias}.",
27
+ mainSummary: "Main OMP session",
28
+ storeClosed: "The Onlyne store is closed.",
29
+
30
+ todoContextRequired: "A todo decision needs a non-empty context alias.",
31
+ todoUnknownAction: "The judge returned an unknown todo action: {action}.",
32
+ todoAdapterApi: "omp-todo-more is installed but does not expose the todo state adapter API this plugin drives.",
33
+ todoRecordTypes: "omp-todo-more uses branch record types this omp-onlyne build does not understand.",
34
+ todoEmptyAssociate: "The judge chose associate, but the todo list is empty.",
35
+ todoNoSuchItem: 'The judge chose associate, but no todo item is titled "{title}".',
36
+ todoAmbiguousTitle: 'More than one todo item is titled "{title}"; the decision needs a phase.',
37
+ todoDuplicateTitle: 'The judge chose append, but the todo list already has an item titled "{title}"; associate instead.',
38
+ todoPhaseRequired: "An append decision needs a non-empty phase name.",
39
+ todoUnexpectedAction: "Unexpected todo action: {action}",
40
+ todoExplanationRequired: "An append decision needs an explanation or a non-empty message body.",
41
+ todoEstimateInvalid: "A todo estimate must be a positive whole number of minutes.",
42
+ todoMissingField: "The judge chose {action} without a {field}.",
43
+ todoGoalRequired: "The judge chose append into an empty list without the goal's objective, measure and doneWhen.",
44
+ todoNoSession: "No live OMP session owns this inbound todo decision; the todo remains owed by the inbound queue.",
45
+ todoMultipleSessions: "More than one live main OMP session is registered; the todo target is ambiguous.",
46
+ todoSourceLine: "Source: {source}/{seq} from {author}",
47
+ todoDefaultPhase: "Tasks",
48
+ todoBadMetadataVersion: "Onlyne todo decision entry {entry} uses an unsupported metadata version.",
49
+ todoNoDecisionRecord: "Onlyne todo decision entry {entry} records no decision.",
50
+ todoNoOrigins: "Onlyne todo metadata on entry {entry} has no origins array; the association cannot be replayed.",
51
+ todoNoLinks: "Onlyne todo metadata on entry {entry} has no links array; the association cannot be replayed.",
52
+ todoUnknownRecordedAction: "Onlyne todo decision on entry {entry} has an unknown action.",
53
+ todoMissingString: "Onlyne todo metadata on entry {entry} has no string {field}.",
54
+
55
+ trustHeader: "Onlyne trust · {phase} · {node}",
56
+ trustFingerprint: "fingerprint: {fingerprint}",
57
+ trustDeclared: "declared address: {declared}",
58
+ trustObserved: "observed address: {observed}",
59
+ trustUnobserved: "not observed",
60
+ trustPolicy: "acquisition policy: {policy}",
61
+ trustJudgement: "judgement: {reason} · {conclusion}",
62
+ trustAllowed: "allowed",
63
+ trustRefused: "refused",
64
+ trustInvited: "This peer's approval waits for yours. Decide it explicitly:",
65
+ trustApproved: "The acquisition policy approved this peer before any human confirmed, and the approval is committed. No action is requested.",
66
+ trustRequested: "Local approval is committed; the exchange completes when the peer confirms. This is a status update; no action is requested.",
67
+ trustExchanged: "Both sides approved. This is a status update; no action is requested.",
68
+ trustRevoked: "Local approval was dropped. This is a status update; no action is requested.",
69
+ trustAcceptAction: 'accept: msg({ delivery_kind: "accept the trust handshake", msg: "accept {node}", meta: { op: "trust_accept", node_id: "{node}", fingerprint: "{fingerprint}" } })',
70
+ trustRevokeAction: 'revoke: msg({ delivery_kind: "revoke the trust handshake", msg: "revoke {node}", meta: { op: "trust_revoke", node_id: "{node}" } })',
71
+ trustPanelOnly: "The acquisition policy is none, so this approval is the human's: only the panel can accept it. You may read and revoke.",
72
+ };
73
+
74
+ const zh: Record<keyof typeof en, string> = {
75
+ noDecision: "这条入站记录没有对应的决策。",
76
+ noContextNamed: "入站决策没有指定上下文。",
77
+ contextLost: "{alias} 已不再绑定;需要一次显式的新决策",
78
+ noLiveSession: "执行上下文 {context} 没有活动会话;必须先重新绑定才能接收消息。",
79
+ noTaskTool: "这个 OMP 会话没有原生 task 工具。",
80
+ noChildIdentity: "原生 task 工具没有返回子代理标识。",
81
+ deliveryReferences: "消息 {message},线程 {thread} 序号 {seq}",
82
+ subagentPreamble: "阅读这条来自 {author} 的 onlyne 投递({references})。",
83
+ subagentCannotSend: "本会话不能发送消息。得出答案后,调用一次 msg 工具起草回复,并把 draft id 报告回来:",
84
+ subagentDraftCall: 'msg({ delivery_kind: "起草回复", msg: <你的回复正文>, meta: { op: "draft", to: {to}, relation: "reply", in_reply_to: { sender: {sender}, id: {id} } } })',
85
+ subagentNoRetry: "这次调用只保存草稿,不发送任何消息;由主会话负责发送。不要重试被拒绝的发送,也不要轮询结果。",
86
+ subagentSolutionSpace: "投递内容决定要做什么;把消息本身当作任务。",
87
+ labelInbound: "入站",
88
+ labelSupplement: "补充",
89
+
90
+ draftNeedsRelation: "回复草稿需要 relation 为 reply 或 followup,并提供 in_reply_to。",
91
+ draftChildScope: "子代理只能对分配给它的上下文所对应的源消息起草回复。",
92
+ draftChildCannotSend: "把 draft_id 交回主会话;子代理不能发送草稿。",
93
+ draftNotFound: "没有名为 {id} 的回复草稿。",
94
+ draftNeedsReference: "回复草稿需要 in_reply_to {sender,id} 或 {thread,seq}。",
95
+ draftSourceUnavailable: "草稿的源消息不可用。",
96
+
97
+ contextNotRegistered: "没有注册名为 {alias} 的执行上下文。",
98
+ mainSummary: "OMP 主会话",
99
+ storeClosed: "Onlyne 存储已关闭。",
100
+
101
+ todoContextRequired: "todo 决策需要非空的上下文别名。",
102
+ todoUnknownAction: "judge 返回了未知的 todo 动作:{action}。",
103
+ todoAdapterApi: "已安装 omp-todo-more,但它没有暴露本插件所使用的 todo 状态适配接口。",
104
+ todoRecordTypes: "omp-todo-more 使用的分支记录类型是这个 omp-onlyne 构建无法理解的。",
105
+ todoEmptyAssociate: "judge 选择了 associate,但 todo 列表是空的。",
106
+ todoNoSuchItem: "judge 选择了 associate,但没有标题为“{title}”的 todo 项。",
107
+ todoAmbiguousTitle: "有多个 todo 项标题为“{title}”;决策需要指定阶段。",
108
+ todoDuplicateTitle: "judge 选择了 append,但 todo 列表已有标题为“{title}”的项;应改用 associate。",
109
+ todoPhaseRequired: "append 决策需要非空的阶段名。",
110
+ todoUnexpectedAction: "意外的 todo 动作:{action}",
111
+ todoExplanationRequired: "append 决策需要说明内容或非空的消息正文。",
112
+ todoEstimateInvalid: "todo 预估必须是正整数分钟。",
113
+ todoMissingField: "judge 选择了 {action},但没有提供 {field}。",
114
+ todoGoalRequired: "judge 选择了向空列表 append,但没有提供目标的 objective、measure 和 doneWhen。",
115
+ todoNoSession: "没有活动的 OMP 会话持有这条入站 todo 决策;该 todo 仍由入站队列保留。",
116
+ todoMultipleSessions: "注册了多个活动的主 OMP 会话;todo 目标不明确。",
117
+ todoSourceLine: "来源:{source}/{seq},来自 {author}",
118
+ todoDefaultPhase: "任务",
119
+ todoBadMetadataVersion: "条目 {entry} 上的 onlyne todo 决策使用了不支持的元数据版本。",
120
+ todoNoDecisionRecord: "条目 {entry} 上的 onlyne todo 决策没有记录任何决策内容。",
121
+ todoNoOrigins: "条目 {entry} 上的 onlyne todo 元数据没有 origins 数组;无法重放该关联。",
122
+ todoNoLinks: "条目 {entry} 上的 onlyne todo 元数据没有 links 数组;无法重放该关联。",
123
+ todoUnknownRecordedAction: "条目 {entry} 上的 onlyne todo 决策含有未知动作。",
124
+ todoMissingString: "条目 {entry} 上的 onlyne todo 元数据缺少字符串字段 {field}。",
125
+
126
+ trustHeader: "Onlyne 信任 · {phase} · {node}",
127
+ trustFingerprint: "指纹:{fingerprint}",
128
+ trustDeclared: "声明地址:{declared}",
129
+ trustObserved: "观测地址:{observed}",
130
+ trustUnobserved: "未观测到",
131
+ trustPolicy: "获取策略:{policy}",
132
+ trustJudgement: "判定:{reason} · {conclusion}",
133
+ trustAllowed: "允许",
134
+ trustRefused: "拒绝",
135
+ trustInvited: "这个对端的批准在等你。请显式决定:",
136
+ trustApproved: "获取策略在任何人工确认之前批准了这个对端,批准已经提交。不索取任何动作。",
137
+ trustRequested: "本地批准已提交;对端确认后交换完成。这是状态更新,不索取任何动作。",
138
+ trustExchanged: "双方都已批准。这是状态更新,不索取任何动作。",
139
+ trustRevoked: "本地批准已撤销。这是状态更新,不索取任何动作。",
140
+ trustAcceptAction: '批准:msg({ delivery_kind: "批准信任握手", msg: "批准 {node}", meta: { op: "trust_accept", node_id: "{node}", fingerprint: "{fingerprint}" } })',
141
+ trustRevokeAction: '撤销:msg({ delivery_kind: "撤销信任握手", msg: "撤销 {node}", meta: { op: "trust_revoke", node_id: "{node}" } })',
142
+ trustPanelOnly: "获取策略是 none,这次批准属于人类:只有面板能接受它。你可以读取与撤销。",
143
+ };
144
+
145
+ export const bridgeText = createTranslator({ en, "zh-CN": zh });
package/src/config.ts ADDED
@@ -0,0 +1,332 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { OnlyneConfig } from "./types.ts";
4
+ import type { Locale } from "@onlyne/node-host/i18n";
5
+ import { hostText as t } from "./host-text.ts";
6
+ import { DEFAULT_ACQUISITION, parseAllowBlock, type AcquirePolicy, type AcquisitionPolicy } from "./acquire.ts";
7
+
8
+ const CONFIG_RELATIVE_PATH = join(".onlyne", "config.toml");
9
+ const REGION_PREFIX = "urn:onlyne:region:";
10
+ const AGENT_PREFIX = "urn:onlyne:agent:";
11
+ /** RFC 0011 §5.1: the one service path every HTTP binding answers on. */
12
+ const SERVICE_PATH = "/onlyne/v3";
13
+ const DEFAULT_BIND = "127.0.0.1";
14
+ const DEFAULT_PAGE_SIZE = 50;
15
+
16
+ /** The scheme of an absolute URI, per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":". */
17
+ const ABSOLUTE_URI = /^[A-Za-z][A-Za-z0-9+.-]*:/;
18
+
19
+ export class ConfigError extends Error {
20
+ readonly code: "missing" | "invalid";
21
+
22
+ constructor(code: "missing" | "invalid", message: string) {
23
+ super(message);
24
+ this.name = "ConfigError";
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ /** `<cwd>/.onlyne/config.toml`; configuration is a project fact. */
30
+ export function configPath(cwd: string): string {
31
+ return join(cwd, CONFIG_RELATIVE_PATH);
32
+ }
33
+
34
+ /**
35
+ * A region is a stable absolute URI. A short name is accepted from the user and
36
+ * saved as `urn:onlyne:region:<URI-encoded name>`; an absolute URI is kept as is.
37
+ */
38
+ export function normalizeRegion(value: string): string {
39
+ const trimmed = value.trim();
40
+ if (trimmed.length === 0) throw new ConfigError("invalid", t("regionEmpty"));
41
+ return ABSOLUTE_URI.test(trimmed) && !/\s/.test(trimmed) ? trimmed : `${REGION_PREFIX}${encodeURIComponent(trimmed)}`;
42
+ }
43
+
44
+ /**
45
+ * A node id is a stable absolute URI. A short name is accepted from the user and
46
+ * saved as `urn:onlyne:agent:<URI-encoded name>`; an absolute URI is kept as is.
47
+ */
48
+ export function normalizeNodeId(value: string): string {
49
+ const trimmed = value.trim();
50
+ if (trimmed.length === 0) throw new ConfigError("invalid", t("nodeIdEmpty"));
51
+ return ABSOLUTE_URI.test(trimmed) && !/\s/.test(trimmed) ? trimmed : `${AGENT_PREFIX}${encodeURIComponent(trimmed)}`;
52
+ }
53
+
54
+ /** The short name of an agent URN, for display in a form; a value without that prefix is returned unchanged. */
55
+ export function shortNodeId(value: string): string {
56
+ if (!value.startsWith(AGENT_PREFIX)) return value;
57
+ const name = value.slice(AGENT_PREFIX.length);
58
+ try {
59
+ return decodeURIComponent(name);
60
+ } catch {
61
+ return name;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * A node address as the rest of the plugin uses it: the absolute service URL. An origin
67
+ * (`http://host:7801`), a service URL, and a bare `host:port` all name the same node, and
68
+ * only the service URL is stored or contacted, so one address has one spelling.
69
+ */
70
+ export function normalizeServiceUrl(value: string): string {
71
+ const raw = value.trim();
72
+ if (raw.length === 0) throw new ConfigError("invalid", t("addressEmpty"));
73
+ // A bare `host:port` reads as a scheme to `URL`; it is the common hand-typed form.
74
+ const candidate = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw) ? raw : `http://${raw}`;
75
+ let url: URL;
76
+ try {
77
+ url = new URL(candidate);
78
+ } catch {
79
+ throw new ConfigError("invalid", t("addressUrl", { value: JSON.stringify(raw) }));
80
+ }
81
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
82
+ throw new ConfigError("invalid", t("addressScheme", { value: JSON.stringify(raw) }));
83
+ }
84
+ const path = url.pathname.replace(/\/+$/, "");
85
+ if (path.length === 0 || path === "/") return `${url.origin}${SERVICE_PATH}`;
86
+ if (path === SERVICE_PATH) return `${url.origin}${SERVICE_PATH}`;
87
+ throw new ConfigError(
88
+ "invalid",
89
+ t("addressShape", { service: SERVICE_PATH, value: JSON.stringify(raw) }),
90
+ );
91
+ }
92
+
93
+ /** Read and validate the project configuration. A missing file is an error, not defaults. */
94
+ export async function loadConfig(cwd: string): Promise<OnlyneConfig> {
95
+ const path = configPath(cwd);
96
+ let text: string;
97
+ try {
98
+ text = await readFile(path, "utf8");
99
+ } catch (error) {
100
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
101
+ throw new ConfigError("missing", t("configMissing", { path }));
102
+ }
103
+ throw error;
104
+ }
105
+ return parseConfig(text, path);
106
+ }
107
+
108
+ /**
109
+ * Validate, then write the project configuration atomically; the file is either
110
+ * the old or the new contents. Returns the configuration as written.
111
+ */
112
+ export async function saveConfig(config: OnlyneConfig): Promise<OnlyneConfig> {
113
+ const next = validateConfig(config);
114
+ const text = serializeConfig(next);
115
+ await mkdir(dirname(next.path), { recursive: true });
116
+ const temporary = `${next.path}.${process.pid}.${Date.now()}.tmp`;
117
+ await writeFile(temporary, text, "utf8");
118
+ await rename(temporary, next.path);
119
+ return next;
120
+ }
121
+
122
+ /** Parse and validate the project configuration. Exported for tests. */
123
+ export function parseConfig(text: string, path: string): OnlyneConfig {
124
+ let raw: Record<string, unknown>;
125
+ try {
126
+ raw = Bun.TOML.parse(text) as Record<string, unknown>;
127
+ } catch (error) {
128
+ throw new ConfigError("invalid", t("configParse", { path, error: error instanceof Error ? error.message : String(error) }));
129
+ }
130
+ const node = table(raw, "node", path);
131
+ const server = table(raw, "server", path);
132
+ const threads = table(raw, "threads", path);
133
+ const discovery = raw.discovery === undefined ? undefined : table(raw, "discovery", path);
134
+ rejectRetired(raw, node, server, threads);
135
+ const id = optionalString(node, "id", "node.id");
136
+ const region = optionalString(node, "region", "node.region");
137
+ const keyRef = optionalString(node, "key_ref", "node.key_ref");
138
+ const keyScope = optionalString(node, "key_scope", "node.key_scope");
139
+ const publicUrl = optionalString(server, "public_url", "server.public_url");
140
+ const port = optionalInteger(server, "port", "server.port");
141
+ const seeds = discovery === undefined ? undefined : stringArray(discovery, "seeds", "discovery.seeds");
142
+ const trust = trustSeeds(raw, path);
143
+ const acquisition = raw.acquisition === undefined ? undefined : table(raw, "acquisition", path);
144
+ const acquirePolicy = acquisition === undefined ? undefined : optionalString(acquisition, "policy", "acquisition.policy");
145
+ const acquireAllow = acquisition === undefined ? undefined : stringArray(acquisition, "allow", "acquisition.allow");
146
+ const bind = optionalString(server, "bind", "server.bind");
147
+ const pageSize = optionalInteger(threads, "page_size", "threads.page_size");
148
+ return validateConfig({
149
+ path,
150
+ ...(raw.language === undefined ? {} : { language: raw.language as Locale }),
151
+ region: region ?? "",
152
+ node: { id: id ?? "", ...(keyRef === undefined ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope as "project" | "global" | "none" }) },
153
+ server: { bind: bind ?? DEFAULT_BIND, ...(port === undefined ? {} : { port }), ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
154
+ threads: { page_size: pageSize ?? DEFAULT_PAGE_SIZE },
155
+ acquisition: { policy: acquirePolicyOf(acquirePolicy), allow: acquireAllow ?? [...DEFAULT_ACQUISITION.allow] },
156
+ ...(seeds === undefined && trust === undefined ? {} : { discovery: { ...(seeds === undefined ? {} : { seeds }), ...(trust === undefined ? {} : { trust }) } }),
157
+ ...(trust === undefined ? {} : { trust }),
158
+ });
159
+ }
160
+
161
+ /** Serialize a validated configuration in a stable section order. */
162
+ export function serializeConfig(config: OnlyneConfig): string {
163
+ const lines: string[] = [
164
+ "# Onlyne project configuration. Credentials stay behind key_ref references.",
165
+ ...(config.language === undefined ? [] : [`language = ${tomlString(config.language)}`, ""]),
166
+ "[node]",
167
+ `id = ${tomlString(config.node.id)}`,
168
+ `region = ${tomlString(config.region)}`,
169
+ ];
170
+ if (config.node.key_ref !== undefined) lines.push(`key_ref = ${tomlString(config.node.key_ref)}`);
171
+ if (config.node.key_scope !== undefined) lines.push(`key_scope = ${tomlString(config.node.key_scope)}`);
172
+ lines.push(
173
+ "",
174
+ "[server]",
175
+ `bind = ${tomlString(config.server.bind)}`,
176
+ ...(config.server.port === undefined ? [] : [`port = ${String(config.server.port)}`]),
177
+ ...(config.server.public_url === undefined ? [] : [`public_url = ${tomlString(config.server.public_url)}`]),
178
+ "",
179
+ "[threads]",
180
+ `page_size = ${String(config.threads.page_size)}`,
181
+ );
182
+ const acquisition = config.acquisition ?? DEFAULT_ACQUISITION;
183
+ lines.push("", "[acquisition]", `policy = ${tomlString(acquisition.policy)}`);
184
+ if (acquisition.allow.length > 0) lines.push(`allow = [${acquisition.allow.map(entry => tomlString(entry)).join(", ")}]`);
185
+ if (config.discovery?.seeds !== undefined || config.trust !== undefined) {
186
+ lines.push("", "[discovery]");
187
+ const seeds = config.discovery?.seeds ?? [];
188
+ if (seeds.length > 0) lines.push(`seeds = [${seeds.map(seed => tomlString(seed)).join(", ")}]`);
189
+ for (const entry of config.trust ?? []) {
190
+ lines.push("", "[[trust]]", `node_id = ${tomlString(entry.node_id)}`, `fingerprint = ${tomlString(entry.fingerprint)}`);
191
+ }
192
+ }
193
+ return `${lines.join("\n")}\n`;
194
+ }
195
+
196
+ function validateConfig(config: OnlyneConfig): OnlyneConfig {
197
+ if (config.language !== undefined && config.language !== "en" && config.language !== "zh-CN") {
198
+ throw new ConfigError("invalid", t("languageInvalid"));
199
+ }
200
+ const region = normalizeRegion(config.region);
201
+ const id = normalizeNodeId(config.node.id);
202
+ const keyRef = config.node.key_ref?.trim();
203
+ const bind = config.server.bind.trim();
204
+ const publicUrl = config.server.public_url?.trim();
205
+ const port = config.server.port;
206
+ const keyScope = config.node.key_scope;
207
+ if (keyScope !== undefined && keyScope !== "project" && keyScope !== "global" && keyScope !== "none") {
208
+ throw new ConfigError("invalid", t("keyScopeInvalid", { value: JSON.stringify(keyScope) }));
209
+ }
210
+ if (publicUrl !== undefined && publicUrl.length === 0) throw new ConfigError("invalid", t("publicUrlEmpty"));
211
+ if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) {
212
+ throw new ConfigError("invalid", t("portRange", { value: String(port) }));
213
+ }
214
+ // A seed is contacted at every start, so it is stored in the one spelling the node uses.
215
+ const seeds = [...new Set((config.discovery?.seeds ?? []).map(seed => seed.trim()).filter(seed => seed.length > 0).map(normalizeServiceUrl))];
216
+ if (bind.length === 0) throw new ConfigError("invalid", t("bindEmpty"));
217
+ if (/[\s/]/.test(bind)) {
218
+ throw new ConfigError("invalid", t("bindShape", { value: JSON.stringify(bind) }));
219
+ }
220
+ const pageSize = config.threads.page_size;
221
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
222
+ throw new ConfigError("invalid", t("pageSizeRange", { value: String(pageSize) }));
223
+ }
224
+ const policy = config.acquisition?.policy ?? DEFAULT_ACQUISITION.policy;
225
+ if (policy !== "all" && policy !== "segment" && policy !== "none") {
226
+ throw new ConfigError("invalid", t("acquirePolicy", { value: JSON.stringify(policy) }));
227
+ }
228
+ const acquisition: AcquisitionPolicy = {
229
+ policy,
230
+ allow: [...new Set((config.acquisition?.allow ?? DEFAULT_ACQUISITION.allow).map(entry => entry.trim()).filter(entry => entry.length > 0))],
231
+ };
232
+ for (const entry of acquisition.allow) {
233
+ if (parseAllowBlock(entry) === undefined) throw new ConfigError("invalid", t("acquireAllow", { value: JSON.stringify(entry) }));
234
+ }
235
+ return {
236
+ path: config.path,
237
+ ...(config.language === undefined ? {} : { language: config.language }),
238
+ region,
239
+ node: { id, ...(keyRef === undefined || keyRef.length === 0 ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope }) },
240
+ server: { bind, ...(port === undefined || port === 0 ? {} : { port }), ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
241
+ threads: { page_size: pageSize },
242
+ acquisition,
243
+ ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
244
+ ...(config.trust === undefined ? {} : { trust: config.trust }),
245
+ };
246
+ }
247
+
248
+ function rejectRetired(
249
+ raw: Record<string, unknown>,
250
+ node: Record<string, unknown>,
251
+ server: Record<string, unknown>,
252
+ threads: Record<string, unknown>,
253
+ ): void {
254
+ rejectKey(node, "endpoint", t("retiredEndpoint"));
255
+ rejectKey(raw, "peers", t("retiredPeers"));
256
+ rejectKey(raw, "omp", t("retiredOmp"));
257
+ rejectKey(threads, "default_attention", t("retiredAttention"));
258
+ }
259
+
260
+ function rejectKey(source: Record<string, unknown>, key: string, message: string): void {
261
+ if (source[key] !== undefined) throw new ConfigError("invalid", message);
262
+ }
263
+
264
+ /** `acquisition.policy`: the documented default when absent; any other word is a configuration error. */
265
+ function acquirePolicyOf(value: string | undefined): AcquirePolicy {
266
+ if (value === undefined) return DEFAULT_ACQUISITION.policy;
267
+ if (value === "all" || value === "segment" || value === "none") return value;
268
+ throw new ConfigError("invalid", t("acquirePolicy", { value: JSON.stringify(value) }));
269
+ }
270
+
271
+ function table(root: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
272
+ const value = root[key];
273
+ if (value === undefined) return {};
274
+ if (!isTable(value)) throw new ConfigError("invalid", t("tableExpected", { path, key }));
275
+ return value;
276
+ }
277
+
278
+ function optionalString(source: Record<string, unknown>, key: string, field: string): string | undefined {
279
+ const value = source[key];
280
+ if (value === undefined) return undefined;
281
+ if (typeof value !== "string") throw new ConfigError("invalid", t("stringExpected", { field }));
282
+ return value;
283
+ }
284
+
285
+ function optionalInteger(source: Record<string, unknown>, key: string, field: string): number | undefined {
286
+ const value = source[key];
287
+ if (value === undefined) return undefined;
288
+ if (typeof value !== "number" || !Number.isInteger(value)) {
289
+ throw new ConfigError("invalid", t("integerExpected", { field }));
290
+ }
291
+ return value;
292
+ }
293
+
294
+ /** An array of non-empty strings under `source.name`. */
295
+ function stringArray(source: Record<string, unknown>, key: string, field: string): string[] | undefined {
296
+ const value = source[key];
297
+ if (value === undefined) return undefined;
298
+ if (!Array.isArray(value) || !value.every(entry => typeof entry === "string" && entry.length > 0)) {
299
+ throw new ConfigError("invalid", t("stringArrayExpected", { field }));
300
+ }
301
+ return value;
302
+ }
303
+
304
+ /** `[[trust]]` rows: a node id and a 16-hex-character fingerprint each. */
305
+ function trustSeeds(raw: Record<string, unknown>, path: string): Array<{ node_id: string; fingerprint: string }> | undefined {
306
+ const value = raw.trust;
307
+ if (value === undefined) return undefined;
308
+ if (!Array.isArray(value)) throw new ConfigError("invalid", t("trustArray", { path }));
309
+ const entries: Array<{ node_id: string; fingerprint: string }> = [];
310
+ for (const row of value) {
311
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
312
+ throw new ConfigError("invalid", t("trustArray", { path }));
313
+ }
314
+ const record = row as Record<string, unknown>;
315
+ const node_id = typeof record.node_id === "string" ? record.node_id.trim() : "";
316
+ const fingerprint = typeof record.fingerprint === "string" ? record.fingerprint.trim() : "";
317
+ if (node_id.length === 0 || !/^[0-9a-f]{16}$/.test(fingerprint)) {
318
+ throw new ConfigError("invalid", t("trustEntry", { path }));
319
+ }
320
+ entries.push({ node_id, fingerprint });
321
+ }
322
+ return entries;
323
+ }
324
+
325
+ function isTable(value: unknown): value is Record<string, unknown> {
326
+ return value !== null && typeof value === "object" && !Array.isArray(value);
327
+ }
328
+
329
+ function tomlString(value: string): string {
330
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
331
+ }
332
+