omp-onlyne 0.0.0-stage → 0.1.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/src/config.ts ADDED
@@ -0,0 +1,300 @@
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
+
7
+ const CONFIG_RELATIVE_PATH = join(".onlyne", "config.toml");
8
+ const REGION_PREFIX = "urn:onlyne:region:";
9
+ const AGENT_PREFIX = "urn:onlyne:agent:";
10
+ /** RFC 0011 §5.1: the one service path every HTTP binding answers on. */
11
+ const SERVICE_PATH = "/onlyne/v3";
12
+ const DEFAULT_BIND = "127.0.0.1";
13
+ const DEFAULT_PAGE_SIZE = 50;
14
+
15
+ /** The scheme of an absolute URI, per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":". */
16
+ const ABSOLUTE_URI = /^[A-Za-z][A-Za-z0-9+.-]*:/;
17
+
18
+ export class ConfigError extends Error {
19
+ readonly code: "missing" | "invalid";
20
+
21
+ constructor(code: "missing" | "invalid", message: string) {
22
+ super(message);
23
+ this.name = "ConfigError";
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ /** `<cwd>/.onlyne/config.toml`; configuration is a project fact. */
29
+ export function configPath(cwd: string): string {
30
+ return join(cwd, CONFIG_RELATIVE_PATH);
31
+ }
32
+
33
+ /**
34
+ * A region is a stable absolute URI. A short name is accepted from the user and
35
+ * saved as `urn:onlyne:region:<URI-encoded name>`; an absolute URI is kept as is.
36
+ */
37
+ export function normalizeRegion(value: string): string {
38
+ const trimmed = value.trim();
39
+ if (trimmed.length === 0) throw new ConfigError("invalid", t("regionEmpty"));
40
+ return ABSOLUTE_URI.test(trimmed) && !/\s/.test(trimmed) ? trimmed : `${REGION_PREFIX}${encodeURIComponent(trimmed)}`;
41
+ }
42
+
43
+ /**
44
+ * A node id is a stable absolute URI. A short name is accepted from the user and
45
+ * saved as `urn:onlyne:agent:<URI-encoded name>`; an absolute URI is kept as is.
46
+ */
47
+ export function normalizeNodeId(value: string): string {
48
+ const trimmed = value.trim();
49
+ if (trimmed.length === 0) throw new ConfigError("invalid", t("nodeIdEmpty"));
50
+ return ABSOLUTE_URI.test(trimmed) && !/\s/.test(trimmed) ? trimmed : `${AGENT_PREFIX}${encodeURIComponent(trimmed)}`;
51
+ }
52
+
53
+ /** The short name of an agent URN, for display in a form; a value without that prefix is returned unchanged. */
54
+ export function shortNodeId(value: string): string {
55
+ if (!value.startsWith(AGENT_PREFIX)) return value;
56
+ const name = value.slice(AGENT_PREFIX.length);
57
+ try {
58
+ return decodeURIComponent(name);
59
+ } catch {
60
+ return name;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * A node address as the rest of the plugin uses it: the absolute service URL. An origin
66
+ * (`http://host:7801`), a service URL, and a bare `host:port` all name the same node, and
67
+ * only the service URL is stored or contacted, so one address has one spelling.
68
+ */
69
+ export function normalizeServiceUrl(value: string): string {
70
+ const raw = value.trim();
71
+ if (raw.length === 0) throw new ConfigError("invalid", t("addressEmpty"));
72
+ // A bare `host:port` reads as a scheme to `URL`; it is the common hand-typed form.
73
+ const candidate = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw) ? raw : `http://${raw}`;
74
+ let url: URL;
75
+ try {
76
+ url = new URL(candidate);
77
+ } catch {
78
+ throw new ConfigError("invalid", t("addressUrl", { value: JSON.stringify(raw) }));
79
+ }
80
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
81
+ throw new ConfigError("invalid", t("addressScheme", { value: JSON.stringify(raw) }));
82
+ }
83
+ const path = url.pathname.replace(/\/+$/, "");
84
+ if (path.length === 0 || path === "/") return `${url.origin}${SERVICE_PATH}`;
85
+ if (path === SERVICE_PATH) return `${url.origin}${SERVICE_PATH}`;
86
+ throw new ConfigError(
87
+ "invalid",
88
+ t("addressShape", { service: SERVICE_PATH, value: JSON.stringify(raw) }),
89
+ );
90
+ }
91
+
92
+ /** Read and validate the project configuration. A missing file is an error, not defaults. */
93
+ export async function loadConfig(cwd: string): Promise<OnlyneConfig> {
94
+ const path = configPath(cwd);
95
+ let text: string;
96
+ try {
97
+ text = await readFile(path, "utf8");
98
+ } catch (error) {
99
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
100
+ throw new ConfigError("missing", t("configMissing", { path }));
101
+ }
102
+ throw error;
103
+ }
104
+ return parseConfig(text, path);
105
+ }
106
+
107
+ /**
108
+ * Validate, then write the project configuration atomically; the file is either
109
+ * the old or the new contents. Returns the configuration as written.
110
+ */
111
+ export async function saveConfig(config: OnlyneConfig): Promise<OnlyneConfig> {
112
+ const next = validateConfig(config);
113
+ const text = serializeConfig(next);
114
+ await mkdir(dirname(next.path), { recursive: true });
115
+ const temporary = `${next.path}.${process.pid}.${Date.now()}.tmp`;
116
+ await writeFile(temporary, text, "utf8");
117
+ await rename(temporary, next.path);
118
+ return next;
119
+ }
120
+
121
+ /** Parse and validate the project configuration. Exported for tests. */
122
+ export function parseConfig(text: string, path: string): OnlyneConfig {
123
+ let raw: Record<string, unknown>;
124
+ try {
125
+ raw = Bun.TOML.parse(text) as Record<string, unknown>;
126
+ } catch (error) {
127
+ throw new ConfigError("invalid", t("configParse", { path, error: error instanceof Error ? error.message : String(error) }));
128
+ }
129
+ const node = table(raw, "node", path);
130
+ const server = table(raw, "server", path);
131
+ const threads = table(raw, "threads", path);
132
+ const discovery = raw.discovery === undefined ? undefined : table(raw, "discovery", path);
133
+ rejectRetired(raw, node, server, threads);
134
+ const id = optionalString(node, "id", "node.id");
135
+ const region = optionalString(node, "region", "node.region");
136
+ const keyRef = optionalString(node, "key_ref", "node.key_ref");
137
+ const keyScope = optionalString(node, "key_scope", "node.key_scope");
138
+ const publicUrl = optionalString(server, "public_url", "server.public_url");
139
+ const seeds = discovery === undefined ? undefined : stringArray(discovery, "seeds", "discovery.seeds");
140
+ const trust = trustSeeds(raw, path);
141
+ const bind = optionalString(server, "bind", "server.bind");
142
+ const pageSize = optionalInteger(threads, "page_size", "threads.page_size");
143
+ return validateConfig({
144
+ path,
145
+ ...(raw.language === undefined ? {} : { language: raw.language as Locale }),
146
+ region: region ?? "",
147
+ node: { id: id ?? "", ...(keyRef === undefined ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope as "project" | "global" | "none" }) },
148
+ server: { bind: bind ?? DEFAULT_BIND, ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
149
+ threads: { page_size: pageSize ?? DEFAULT_PAGE_SIZE },
150
+ ...(seeds === undefined && trust === undefined ? {} : { discovery: { ...(seeds === undefined ? {} : { seeds }), ...(trust === undefined ? {} : { trust }) } }),
151
+ ...(trust === undefined ? {} : { trust }),
152
+ });
153
+ }
154
+
155
+ /** Serialize a validated configuration in a stable section order. */
156
+ export function serializeConfig(config: OnlyneConfig): string {
157
+ const lines: string[] = [
158
+ "# Onlyne project configuration. Credentials stay behind key_ref references.",
159
+ ...(config.language === undefined ? [] : [`language = ${tomlString(config.language)}`, ""]),
160
+ "[node]",
161
+ `id = ${tomlString(config.node.id)}`,
162
+ `region = ${tomlString(config.region)}`,
163
+ ];
164
+ if (config.node.key_ref !== undefined) lines.push(`key_ref = ${tomlString(config.node.key_ref)}`);
165
+ if (config.node.key_scope !== undefined) lines.push(`key_scope = ${tomlString(config.node.key_scope)}`);
166
+ lines.push(
167
+ "",
168
+ "[server]",
169
+ `bind = ${tomlString(config.server.bind)}`,
170
+ ...(config.server.public_url === undefined ? [] : [`public_url = ${tomlString(config.server.public_url)}`]),
171
+ "",
172
+ "[threads]",
173
+ `page_size = ${String(config.threads.page_size)}`,
174
+ );
175
+ if (config.discovery?.seeds !== undefined || config.trust !== undefined) {
176
+ lines.push("", "[discovery]");
177
+ const seeds = config.discovery?.seeds ?? [];
178
+ if (seeds.length > 0) lines.push(`seeds = [${seeds.map(seed => tomlString(seed)).join(", ")}]`);
179
+ for (const entry of config.trust ?? []) {
180
+ lines.push("", "[[trust]]", `node_id = ${tomlString(entry.node_id)}`, `fingerprint = ${tomlString(entry.fingerprint)}`);
181
+ }
182
+ }
183
+ return `${lines.join("\n")}\n`;
184
+ }
185
+
186
+ function validateConfig(config: OnlyneConfig): OnlyneConfig {
187
+ if (config.language !== undefined && config.language !== "en" && config.language !== "zh-CN") {
188
+ throw new ConfigError("invalid", t("languageInvalid"));
189
+ }
190
+ const region = normalizeRegion(config.region);
191
+ const id = normalizeNodeId(config.node.id);
192
+ const keyRef = config.node.key_ref?.trim();
193
+ const bind = config.server.bind.trim();
194
+ const publicUrl = config.server.public_url?.trim();
195
+ const keyScope = config.node.key_scope;
196
+ if (keyScope !== undefined && keyScope !== "project" && keyScope !== "global" && keyScope !== "none") {
197
+ throw new ConfigError("invalid", t("keyScopeInvalid", { value: JSON.stringify(keyScope) }));
198
+ }
199
+ if (publicUrl !== undefined && publicUrl.length === 0) throw new ConfigError("invalid", t("publicUrlEmpty"));
200
+ // A seed is contacted at every start, so it is stored in the one spelling the node uses.
201
+ const seeds = [...new Set((config.discovery?.seeds ?? []).map(seed => seed.trim()).filter(seed => seed.length > 0).map(normalizeServiceUrl))];
202
+ if (bind.length === 0) throw new ConfigError("invalid", t("bindEmpty"));
203
+ if (/[\s/]/.test(bind)) {
204
+ throw new ConfigError("invalid", t("bindShape", { value: JSON.stringify(bind) }));
205
+ }
206
+ const pageSize = config.threads.page_size;
207
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
208
+ throw new ConfigError("invalid", t("pageSizeRange", { value: String(pageSize) }));
209
+ }
210
+ return {
211
+ path: config.path,
212
+ ...(config.language === undefined ? {} : { language: config.language }),
213
+ region,
214
+ node: { id, ...(keyRef === undefined || keyRef.length === 0 ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope }) },
215
+ server: { bind, ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
216
+ threads: { page_size: pageSize },
217
+ ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
218
+ ...(config.trust === undefined ? {} : { trust: config.trust }),
219
+ };
220
+ }
221
+
222
+ function rejectRetired(
223
+ raw: Record<string, unknown>,
224
+ node: Record<string, unknown>,
225
+ server: Record<string, unknown>,
226
+ threads: Record<string, unknown>,
227
+ ): void {
228
+ rejectKey(node, "endpoint", t("retiredEndpoint"));
229
+ rejectKey(server, "port", t("retiredPort"));
230
+ rejectKey(raw, "peers", t("retiredPeers"));
231
+ rejectKey(raw, "omp", t("retiredOmp"));
232
+ rejectKey(threads, "default_attention", t("retiredAttention"));
233
+ }
234
+
235
+ function rejectKey(source: Record<string, unknown>, key: string, message: string): void {
236
+ if (source[key] !== undefined) throw new ConfigError("invalid", message);
237
+ }
238
+
239
+ function table(root: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
240
+ const value = root[key];
241
+ if (value === undefined) return {};
242
+ if (!isTable(value)) throw new ConfigError("invalid", t("tableExpected", { path, key }));
243
+ return value;
244
+ }
245
+
246
+ function optionalString(source: Record<string, unknown>, key: string, field: string): string | undefined {
247
+ const value = source[key];
248
+ if (value === undefined) return undefined;
249
+ if (typeof value !== "string") throw new ConfigError("invalid", t("stringExpected", { field }));
250
+ return value;
251
+ }
252
+
253
+ function optionalInteger(source: Record<string, unknown>, key: string, field: string): number | undefined {
254
+ const value = source[key];
255
+ if (value === undefined) return undefined;
256
+ if (typeof value !== "number" || !Number.isInteger(value)) {
257
+ throw new ConfigError("invalid", t("integerExpected", { field }));
258
+ }
259
+ return value;
260
+ }
261
+
262
+ /** An array of non-empty strings under `source.name`. */
263
+ function stringArray(source: Record<string, unknown>, key: string, field: string): string[] | undefined {
264
+ const value = source[key];
265
+ if (value === undefined) return undefined;
266
+ if (!Array.isArray(value) || !value.every(entry => typeof entry === "string" && entry.length > 0)) {
267
+ throw new ConfigError("invalid", t("stringArrayExpected", { field }));
268
+ }
269
+ return value;
270
+ }
271
+
272
+ /** `[[trust]]` rows: a node id and a 16-hex-character fingerprint each. */
273
+ function trustSeeds(raw: Record<string, unknown>, path: string): Array<{ node_id: string; fingerprint: string }> | undefined {
274
+ const value = raw.trust;
275
+ if (value === undefined) return undefined;
276
+ if (!Array.isArray(value)) throw new ConfigError("invalid", t("trustArray", { path }));
277
+ const entries: Array<{ node_id: string; fingerprint: string }> = [];
278
+ for (const row of value) {
279
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
280
+ throw new ConfigError("invalid", t("trustArray", { path }));
281
+ }
282
+ const record = row as Record<string, unknown>;
283
+ const node_id = typeof record.node_id === "string" ? record.node_id.trim() : "";
284
+ const fingerprint = typeof record.fingerprint === "string" ? record.fingerprint.trim() : "";
285
+ if (node_id.length === 0 || !/^[0-9a-f]{16}$/.test(fingerprint)) {
286
+ throw new ConfigError("invalid", t("trustEntry", { path }));
287
+ }
288
+ entries.push({ node_id, fingerprint });
289
+ }
290
+ return entries;
291
+ }
292
+
293
+ function isTable(value: unknown): value is Record<string, unknown> {
294
+ return value !== null && typeof value === "object" && !Array.isArray(value);
295
+ }
296
+
297
+ function tomlString(value: string): string {
298
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
299
+ }
300
+