@onlyne/node-host 0.0.0-stage → 3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dbydd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # @onlyne/node-host
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The host half that every Onlyne node shares: identity keys on disk, DNS-SD discovery, the
4
+ gateway trust terminal, a bounded log ring, and the English/Simplified Chinese message
5
+ catalogs.
6
+
7
+ The library owns protocol state. This package owns the filesystem and terminal work that the
8
+ library is not allowed to do.
9
+
10
+ ## Entry points
11
+
12
+ | Export | Contents |
13
+ |---|---|
14
+ | `@onlyne/node-host` | `startNodeHost`, `GatewayTui`, `createLogger`, `NetworkDiscovery` |
15
+ | `@onlyne/node-host/keys` | `createNodeKey`, `loadOrCreateKey`, `writeKeyMaterial`, `ensureKeyDir` |
16
+ | `@onlyne/node-host/network` | DNS-SD publish and browse over `bonjour-service` |
17
+ | `@onlyne/node-host/i18n` | `Locale`, `resolveLocale`, `getLocale`, `setLocale`, `createTranslator` |
18
+
19
+ `startNodeHost` publishes the bound endpoint, browses for peers, re-observes the catalog every
20
+ 30 seconds, and on a terminal hands the operator the trust screen. Discovery only adds
21
+ candidates: a key exchange still needs both fingerprints confirmed.
22
+
23
+ `startNodeHost({ status })` accepts an async callback that returns gateway-specific display lines.
24
+ The terminal refreshes these lines with its trust view once per second. Hosts use them for connection
25
+ state, server tool counts, scheduled tasks, and delivery queues. `ONLYNE_NO_TUI=1` keeps JSON logging
26
+ and skips terminal startup.
27
+
28
+ ## Log levels
29
+
30
+ `createLogger` returns one callable line writer plus the other three levels: calling it, and
31
+ `logger.log`, write `info`, so every existing host line keeps its level. `ONLYNE_LOG_LEVEL` selects
32
+ the threshold, default `info`; a fourth argument overrides it for one process. Every level that
33
+ passes goes to the ring the TUI renders and to the file sink, and each JSON line names its `level`.
34
+
35
+ | Level | Meaning | Examples |
36
+ |---|---|---|
37
+ | `debug` | Per-pass work and protocol internals, off by default | reconcile pulls, replays, injected-context details |
38
+ | `info` | State transitions and business milestones, one line each | `onlyne.tui.ready`, `connector.state`, a committed send |
39
+ | `warn` | Degraded but running | reconnects, retries, an unconfirmed delivery, a rate-limit hit, a failed reconcile pull |
40
+ | `error` | The operation failed and the caller lost something | a received event that could not be stored |
41
+
42
+ A sink that is a plain function rather than a `createLogger` result records every line it is
43
+ handed: it has no threshold of its own, so filtering belongs to the host that built it.
44
+
45
+ ## Language
46
+
47
+ `resolveLocale` reads an explicit value first, then `ONLYNE_LANG`, then `LC_ALL` or `LANG`, and
48
+ falls back to `en`. A module that renders text keeps its own `en` and `zh-CN` catalogs and calls
49
+ `createTranslator`; the returned function reads the process locale on every call.
50
+
51
+ ## Runtime
52
+
53
+ The package ships TypeScript source for Bun. It performs filesystem access, so it belongs to the
54
+ host, never to `@onlyne/lib`.
55
+
56
+ ## License
57
+
58
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@onlyne/node-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.1.0",
4
+ "description": "Shared Onlyne node host pieces: identity keys, DNS-SD discovery, the gateway trust terminal, and message catalogs",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/dbydd/onlyne.git",
12
+ "directory": "packages/node-host"
13
+ },
14
+ "homepage": "https://github.com/dbydd/onlyne#readme",
15
+ "bugs": "https://github.com/dbydd/onlyne/issues",
16
+ "keywords": ["onlyne", "agent", "discovery", "keys", "i18n"],
17
+ "publishConfig": { "access": "public" },
18
+ "engines": { "bun": ">=1.4.3" },
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./keys": "./src/keys.ts",
22
+ "./network": "./src/network.ts",
23
+ "./i18n": "./src/i18n.ts"
24
+ },
25
+ "files": ["src", "README.md", "LICENSE"],
26
+ "scripts": { "check": "tsc --noEmit" },
27
+ "dependencies": {
28
+ "@onlyne/lib": "^3.1.0",
29
+ "bonjour-service": "^1.4.4"
30
+ },
31
+ "devDependencies": { "@types/bun": "^1.3.0", "typescript": "^5.9.0" }
32
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type Locale = 'en' | 'zh-CN';
2
+ export const LOCALES: readonly Locale[] = ['en', 'zh-CN'];
3
+
4
+ export function parseLocale(value: string | null | undefined): Locale | undefined {
5
+ const language = value?.trim().toLowerCase().replace(/_/g, '-');
6
+ if (language === 'en' || language?.startsWith('en-') || language?.startsWith('en.')) return 'en';
7
+ if (language === 'zh' || language?.startsWith('zh-') || language?.startsWith('zh.')) return 'zh-CN';
8
+ return undefined;
9
+ }
10
+
11
+ export function resolveLocale(explicit?: string | null, env: Record<string, string | undefined> = process.env): Locale {
12
+ return parseLocale(explicit) ?? parseLocale(env.ONLYNE_LANG) ?? parseLocale(env.LC_ALL || env.LANG) ?? 'en';
13
+ }
14
+
15
+ let locale: Locale = resolveLocale();
16
+ export function getLocale(): Locale { return locale; }
17
+ export function setLocale(value: Locale): void { locale = value; }
18
+ export type Catalog<K extends string> = Record<Locale, Record<K, string>>;
19
+
20
+ export function createTranslator<K extends string>(catalog: Catalog<K>): (key: K, vars?: Record<string, string | number>) => string {
21
+ return (key, vars) => catalog[getLocale()][key].replace(/\{(\w+)\}/g, (match, name: string) => vars?.[name] === undefined ? match : String(vars[name]));
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { OnlyneNode, ServiceDescription } from '@onlyne/lib';
2
+ import { NetworkDiscovery } from './network.ts';
3
+ import type { LogRing } from './log.ts';
4
+ import { GatewayTui } from './tui.ts';
5
+
6
+ export { createNodeKey } from './keys.ts';
7
+ export { NetworkDiscovery } from './network.ts';
8
+ export { createLogger, leveled, resolveLogLevel } from './log.ts';
9
+ export type { GatewayLogger, LeveledLog, LogLevel, LogLine, LogRing, LogSink } from './log.ts';
10
+ export { GatewayTui } from './tui.ts';
11
+
12
+ export interface NodeHostOptions {
13
+ node: OnlyneNode;
14
+ endpoint: string;
15
+ bind: string;
16
+ seeds?: string[];
17
+ /** The log ring the TUI renders; the host's logger owns it. */
18
+ ring?: LogRing;
19
+ /** Defaults to the terminal surface when both ends of the process are a TTY. */
20
+ interactive?: boolean;
21
+ /** Gateway-specific state rendered with the trust terminal. */
22
+ status?: () => Promise<string[]>;
23
+ log(message: string, fields?: Record<string, unknown>): void;
24
+ }
25
+
26
+ /**
27
+ * The host half every gateway shares: publish the bound endpoint over DNS-SD, browse for
28
+ * peers, re-observe the catalog, and — on a terminal — hand the operator the trust surface.
29
+ * Discovery only adds candidates; a key exchange still needs both fingerprints confirmed.
30
+ */
31
+ export async function startNodeHost(options: NodeHostOptions): Promise<{ close(): Promise<void> }> {
32
+ const { node, log } = options;
33
+ const controller = new AbortController();
34
+ const response = await node.fetch(new Request(`${options.endpoint}/description`));
35
+ const description = await response.json() as ServiceDescription;
36
+ const network = new NetworkDiscovery({
37
+ node_id: node.auth.actor, instance_id: description.instance_id, endpoint: options.endpoint, bind: options.bind,
38
+ onCandidate: async (url, source, expectedNode) => {
39
+ if (expectedNode === node.auth.actor) return;
40
+ await node.discovery.observe(url, source, expectedNode, controller.signal);
41
+ },
42
+ onError: error => log('onlyne.discovery.failed', { error: String(error) }),
43
+ });
44
+ try { await network.start(); }
45
+ catch (error) { log('onlyne.discovery.unavailable', { error: String(error) }); }
46
+ for (const seed of options.seeds ?? []) {
47
+ try { await node.discovery.observe(seed, 'bootstrap', undefined, controller.signal); }
48
+ catch (error) { log('onlyne.discovery.failed', { seed, error: String(error) }); }
49
+ }
50
+ let refreshing = false;
51
+ const timer = setInterval(() => {
52
+ if (refreshing) return;
53
+ refreshing = true;
54
+ void node.discovery.refresh(controller.signal)
55
+ .catch(error => { if (!controller.signal.aborted) log('onlyne.discovery.failed', { error: String(error) }); })
56
+ .finally(() => { refreshing = false; });
57
+ }, 30_000);
58
+
59
+ let tui: GatewayTui | undefined;
60
+ // `ONLYNE_NO_TUI=1` keeps the plain JSON log on stdout, for a pipe or a screen a TUI would spoil.
61
+ const interactive = (options.interactive ?? true) && process.env.ONLYNE_NO_TUI !== '1';
62
+ if (interactive && options.ring !== undefined && process.stdin.isTTY && process.stdout.isTTY) {
63
+ const surface = new GatewayTui(node, options.ring, options.endpoint, undefined, options.status);
64
+ try {
65
+ log('onlyne.tui.starting');
66
+ await surface.start();
67
+ tui = surface;
68
+ log('onlyne.tui.ready');
69
+ } catch (error) {
70
+ log('onlyne.tui.unavailable', { error: String(error) });
71
+ }
72
+ }
73
+
74
+ return {
75
+ async close(): Promise<void> {
76
+ clearInterval(timer);
77
+ await tui?.close();
78
+ controller.abort();
79
+ await network.close();
80
+ },
81
+ };
82
+ }
package/src/keys.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import type { KeyHook, KeyMaterial } from "@onlyne/lib";
6
+ import { execFile } from "node:child_process";
7
+ import { generateKeyMaterial } from "@onlyne/lib";
8
+
9
+ /** RFC 0017 §2: the private key rides in its own file as base64 DER; the public half is derived on load. */
10
+ const PRIVATE_NAME = "priv.pem";
11
+ const PUBLIC_NAME = "pub.key";
12
+
13
+ function projectKeysRoot(cwd: string): string {
14
+ return join(cwd, ".onlyne", "keys");
15
+ }
16
+
17
+ function globalKeysRoot(): string {
18
+ return join(homedir(), ".config", "onlyne", "keys");
19
+ }
20
+
21
+ function encodePrivate(material: KeyMaterial): string {
22
+ return Buffer.from(material.private_key).toString("base64");
23
+ }
24
+
25
+ function decodePrivate(text: string): Uint8Array {
26
+ return new Uint8Array(Buffer.from(text, "base64"));
27
+ }
28
+
29
+ /** Refuses group/world-readable private keys and symlinked or foreign-owned files. */
30
+ function assertPrivatePermissions(path: string): void {
31
+ const stats = statSync(path, { throwIfNoEntry: false });
32
+ if (stats === undefined) return;
33
+ if (stats.isSymbolicLink()) throw new Error(`The node key must not be a symlink: ${path}`);
34
+ if ((stats.mode & 0o077) !== 0) {
35
+ throw new Error(`The node key is readable beyond its owner. Fix it with: chmod 600 ${path}`);
36
+ }
37
+ }
38
+
39
+ async function assertDirectoryPermissions(path: string): Promise<void> {
40
+ const stats = await statSync(path, { throwIfNoEntry: false });
41
+ if (stats === undefined || (stats.mode & 0o077) !== 0) {
42
+ throw new Error(`The key directory is readable beyond its owner. Fix it with: chmod 700 ${path}`);
43
+ }
44
+ }
45
+
46
+ /** The node id names a subdirectory; a traversal-looking id is a configuration error. */
47
+ function keyDirectory(root: string, nodeId: string): string {
48
+ if (nodeId.includes("/") || nodeId.includes("\\") || nodeId.includes("..")) {
49
+ throw new Error(`The node id cannot name a key directory: ${nodeId}`);
50
+ }
51
+ return join(root, encodeURIComponent(nodeId));
52
+ }
53
+
54
+ /**
55
+ * The git check is `git check-ignore --quiet`, the real semantics: an ancestor ignore,
56
+ * a negation rule, or a .git/info/exclude all read the same way the user's git does.
57
+ */
58
+ function ignoredByGit(root: string, target: string): Promise<boolean> {
59
+ return new Promise(resolve => {
60
+ // `cwd` is the project root: the key directory does not exist yet on a first run.
61
+ execFile("git", ["check-ignore", "--quiet", "--", target], { cwd: root }, error => {
62
+ // Exit 0 = ignored, exit 1 = tracked-or-untracked but not ignored, other = outside a work tree.
63
+ resolve(error === null || (typeof (error as { code?: number }).code === "number" && (error as { code?: number }).code === 0));
64
+ });
65
+ });
66
+ }
67
+
68
+ async function insideGitWorkTree(path: string): Promise<boolean> {
69
+ return new Promise(resolve => {
70
+ execFile("git", ["rev-parse", "--is-inside-work-tree"], { cwd: path }, (error, stdout) => {
71
+ resolve(error === null && stdout.trim() === "true");
72
+ });
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Resolves the key directory for a node (RFC 0017 §2). A project key inside a git work
78
+ * tree whose ignore rules do not cover `.onlyne/keys` is refused, with both escapes named.
79
+ */
80
+ export async function ensureKeyDir(cwd: string, nodeId: string, scope: "project" | "global"): Promise<string> {
81
+ // The global directory holds the node's identity by default; a project directory is an
82
+ // override for this working tree, read first when both exist.
83
+ const root = scope === "project" ? projectKeysRoot(cwd) : globalKeysRoot();
84
+ const dir = keyDirectory(root, nodeId);
85
+ if (scope === "global") {
86
+ await mkdir(dir, { recursive: true });
87
+ await chmod(dir, 0o700);
88
+ return dir;
89
+ }
90
+ if (await insideGitWorkTree(cwd) && !(await ignoredByGit(cwd, dir)) && !(await ignoredByGit(cwd, projectKeysRoot(cwd)))) {
91
+ throw new Error(
92
+ "Refusing to create a node key inside a git work tree: .onlyne/keys is not ignored. "
93
+ + "Add '.onlyne/keys' to .gitignore, or set node.key_scope = \"global\" in the config.",
94
+ );
95
+ }
96
+ await mkdir(dir, { recursive: true });
97
+ await chmod(dir, 0o700);
98
+ return dir;
99
+ }
100
+
101
+ /** Loads the node's identity key, generating it on first use. */
102
+ export async function loadOrCreateKey(dir: string, nodeId: string): Promise<KeyMaterial> {
103
+ await assertDirectoryPermissions(dir);
104
+ const privatePath = join(dir, PRIVATE_NAME);
105
+ if (!existsSync(privatePath)) {
106
+ const material = generateKeyMaterial();
107
+ await writeKeyMaterial(dir, material);
108
+ return material;
109
+ }
110
+ assertPrivatePermissions(privatePath);
111
+ const base: { private_key: Uint8Array; public_key: Uint8Array } = {
112
+ private_key: decodePrivate((await readFile(privatePath, "utf8")).trim()),
113
+ public_key: new Uint8Array(),
114
+ };
115
+ // The public half always comes from the private key: the file is a cache, never a source.
116
+ const { createPrivateKey, createPublicKey } = await import("node:crypto");
117
+ const pair = createPrivateKey({ key: Buffer.from(base.private_key), format: "der", type: "pkcs8" });
118
+ const jwk = createPublicKey(pair).export({ format: "jwk" });
119
+ return { private_key: base.private_key, public_key: new Uint8Array(Buffer.from(String(jwk.x), "base64url")) };
120
+ }
121
+
122
+ /**
123
+ * Writes the key pair atomically. `replace` is the `plugins.keys.replace` path — the only
124
+ * caller allowed to overwrite an existing private key, because `trust.rotate` has already
125
+ * committed the rotation window and must complete the swap.
126
+ */
127
+ export async function writeKeyMaterial(dir: string, material: KeyMaterial, options: { replace?: boolean } = {}): Promise<void> {
128
+ const privatePath = join(dir, PRIVATE_NAME);
129
+ if (existsSync(privatePath) && options.replace !== true) throw new Error(`A node key already exists at ${privatePath}; use trust rotation instead of overwriting it.`);
130
+ const publicPath = join(dir, PUBLIC_NAME);
131
+ const stagingPrivate = join(dir, `.${basename(PRIVATE_NAME)}.${process.pid}.tmp`);
132
+ const stagingPublic = join(dir, `.${basename(PUBLIC_NAME)}.${process.pid}.tmp`);
133
+ // Every fallible step runs before the private key is renamed: a replace that throws must
134
+ // leave the old key usable, or the rotation proof for peers pinning it is gone for good.
135
+ await writeFile(stagingPrivate, encodePrivate(material), { mode: 0o600 });
136
+ await chmod(stagingPrivate, 0o600);
137
+ await writeFile(stagingPublic, Buffer.from(material.public_key).toString("base64"), { mode: 0o644 });
138
+ await chmod(stagingPublic, 0o644);
139
+ // The public file is a cache derived from the private key, so it may be replaced first.
140
+ await rename(stagingPublic, publicPath);
141
+ await rename(stagingPrivate, privatePath);
142
+ }
143
+
144
+ /** Synchronous guard for the startup path: the existing key must already be private. */
145
+ export function assertKeyPermissions(dir: string): void {
146
+ assertPrivatePermissions(join(dir, PRIVATE_NAME));
147
+ void chmod;
148
+ void chmodSync;
149
+ void mkdirSync;
150
+ void readFileSync;
151
+ }
152
+
153
+ export async function createNodeKey(nodeId: string): Promise<KeyHook> {
154
+ const dir = await ensureKeyDir(process.cwd(), nodeId, "global");
155
+ return Object.assign(() => loadOrCreateKey(dir, nodeId), {
156
+ replace: (material: KeyMaterial) => writeKeyMaterial(dir, material, { replace: true }),
157
+ });
158
+ }
package/src/log.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ /** A bounded tail of the gateway's own log lines, so the TUI can show them. */
4
+ export interface LogRing {
5
+ push(key: string, line: string): void;
6
+ tail(count: number): string[];
7
+ }
8
+
9
+ /** The four levels of the convention: `debug` loops, `info` milestones, `warn` degradation, `error` loss. */
10
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
11
+
12
+ /** One JSON line about to be written. */
13
+ export type LogLine = (message: string, fields?: Record<string, unknown>) => void;
14
+
15
+ /**
16
+ * A host's log sink. Calling it writes `info`, the level every existing call site uses; a sink
17
+ * that also carries `debug`/`warn`/`error` records those levels where the host put them.
18
+ */
19
+ export type LogSink = LogLine & {
20
+ readonly debug?: LogLine;
21
+ readonly warn?: LogLine;
22
+ readonly error?: LogLine;
23
+ };
24
+
25
+ export type LeveledLog = LogLine & {
26
+ log: LogLine;
27
+ debug: LogLine;
28
+ warn: LogLine;
29
+ error: LogLine;
30
+ };
31
+
32
+ export type GatewayLogger = LeveledLog & { readonly level: LogLevel; readonly ring: LogRing };
33
+
34
+ const ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
35
+
36
+ /** `ONLYNE_LOG_LEVEL` selects a level; anything unrecognised (and unset) keeps `info`. */
37
+ export function resolveLogLevel(value: string | undefined): LogLevel {
38
+ return value === 'debug' || value === 'warn' || value === 'error' ? value : 'info';
39
+ }
40
+
41
+ /**
42
+ * Normalizes a sink to the four entry points. A sink without level methods records every line
43
+ * it is handed: it cannot filter, so the host's own store decides what the level means.
44
+ */
45
+ export function leveled(sink?: LogSink): LeveledLog {
46
+ const call: LogLine = (message, fields) => { if (typeof sink === 'function') sink(message, fields); };
47
+ return Object.assign(call, {
48
+ log: call,
49
+ debug: sink?.debug ?? call,
50
+ warn: sink?.warn ?? call,
51
+ error: sink?.error ?? call,
52
+ });
53
+ }
54
+
55
+ /**
56
+ * The gateway logs JSON lines. Under the TUI those lines would corrupt the alternate
57
+ * screen, so they go to a ring the TUI renders instead; without a TTY they stay on stdout.
58
+ * The returned function is the `info` line — `logger.log` is the same entry point — and
59
+ * `debug`, `warn`, `error` are the other levels of the same convention.
60
+ */
61
+ export function createLogger(toStdout: boolean, capacity = 200, file?: string, level?: LogLevel): GatewayLogger {
62
+ const selected = level ?? resolveLogLevel(process.env.ONLYNE_LOG_LEVEL);
63
+ const threshold = ORDER[selected];
64
+ // One entry per distinct message: a gateway that retries every five seconds would otherwise
65
+ // fill the pane with the same line and hide everything else.
66
+ const lines: Array<{ key: string; line: string; count: number }> = [];
67
+ const ring: LogRing = {
68
+ push(key: string, line: string): void {
69
+ const last = lines.at(-1);
70
+ if (last !== undefined && last.key === key) {
71
+ last.line = line;
72
+ last.count += 1;
73
+ return;
74
+ }
75
+ lines.push({ key, line, count: 1 });
76
+ if (lines.length > capacity) lines.splice(0, lines.length - capacity);
77
+ },
78
+ tail(count: number): string[] {
79
+ return lines.slice(Math.max(0, lines.length - count))
80
+ .map(entry => (entry.count === 1 ? entry.line : `${entry.line} ×${entry.count}`));
81
+ },
82
+ };
83
+ const emit = (at: LogLevel, message: string, fields?: Record<string, unknown>): void => {
84
+ if (ORDER[at] < threshold) return;
85
+ const line = JSON.stringify({ at: new Date().toISOString(), level: at, message, ...fields });
86
+ if (toStdout) console.log(line);
87
+ else ring.push(`${at}|${message}|${JSON.stringify(fields ?? {})}`, line);
88
+ // The TUI owns the screen, so the durable record is the file; without one the line would
89
+ // exist only in the ring and vanish with the process.
90
+ if (file !== undefined) {
91
+ try { appendFileSync(file, `${line}\n`); } catch { /* a full disk must not stop the gateway */ }
92
+ }
93
+ };
94
+ const info: LogLine = (message, fields) => emit('info', message, fields);
95
+ return Object.assign(info, {
96
+ level: selected,
97
+ log: info,
98
+ debug: (message: string, fields?: Record<string, unknown>) => emit('debug', message, fields),
99
+ warn: (message: string, fields?: Record<string, unknown>) => emit('warn', message, fields),
100
+ error: (message: string, fields?: Record<string, unknown>) => emit('error', message, fields),
101
+ ring,
102
+ });
103
+ }
package/src/network.ts ADDED
@@ -0,0 +1,382 @@
1
+ import { networkInterfaces } from "node:os";
2
+
3
+ /** DNS-SD types used by the host adapter. */
4
+ interface BonjourService {
5
+ fqdn: string;
6
+ name: string;
7
+ port: number;
8
+ host?: string;
9
+ addresses?: string[];
10
+ txt: unknown;
11
+ referer?: { address?: string };
12
+ records(): Array<{ type: string }>;
13
+ }
14
+ interface BonjourBrowser {
15
+ on(event: "up" | "srv-update" | "txt-update" | "down", listener: (service: BonjourService) => void): void;
16
+ stop(): void;
17
+ }
18
+ interface BonjourInstance {
19
+ find(options: { type: string; protocol: string }): BonjourBrowser;
20
+ publish(options: { name: string; type: string; protocol: string; port: number; host: string; probe: boolean; txt: Record<string, string> }): BonjourService;
21
+ unpublishAll(callback: () => void): void;
22
+ destroy(callback: () => void): void;
23
+ }
24
+ type BonjourConstructor = new (options: object, error: (error: unknown) => void) => BonjourInstance;
25
+
26
+ /** RFC 0011 §5.1: the one service type every HTTP binding publishes. */
27
+ const SERVICE_TYPE = "onlyne";
28
+ const SERVICE_PROTOCOL = "tcp";
29
+ const WIRE_PROTOCOL = "onlyne/3";
30
+ const SERVICE_PATH_SUFFIX = "/onlyne/v3";
31
+ const SOURCE = "mdns";
32
+
33
+ export interface NetworkDiscoveryOptions {
34
+ node_id: string;
35
+ /** Identifies this process instance in the service name and TXT record. */
36
+ instance_id: string;
37
+ /** Absolute URL the node is listening on; its port is the allocated one. */
38
+ endpoint: string;
39
+ /** Local listener address; a wildcard binding advertises a concrete interface. */
40
+ bind: string;
41
+ /** `expectedNode` is the identity the advertisement claims, not a verified fact. */
42
+ onCandidate(url: string, source: string, expectedNode?: string): Promise<void>;
43
+ onError(error: unknown): void;
44
+ /** A candidate that was delivered earlier is no longer advertised. */
45
+ onUnavailable?(url: string): void;
46
+ }
47
+
48
+ /**
49
+ * Same-link discovery over DNS-SD/mDNS: publishes `_onlyne._tcp.local` and
50
+ * offers every advertisement to the library, which owns identity resolution
51
+ * and membership. The browser keeps other regions' candidates; only the
52
+ * HTTP declaration decides region.
53
+ */
54
+ export class NetworkDiscovery {
55
+ readonly #options: NetworkDiscoveryOptions;
56
+ #bonjour: BonjourInstance | undefined;
57
+ #browser: BonjourBrowser | undefined;
58
+ #service: BonjourService | undefined;
59
+ #started = false;
60
+ #closed: Promise<void> | undefined;
61
+ #closing = false;
62
+ /** Deliveries in flight, keyed by URL, so repeated advertisements run once. */
63
+ #inflight = new Map<string, Promise<boolean>>();
64
+ /** Delivered URL per service fqdn; a change or a goodbye withdraws the old one. */
65
+ #delivered = new Map<string, string>();
66
+
67
+ constructor(options: NetworkDiscoveryOptions) {
68
+ this.#options = options;
69
+ }
70
+
71
+ async start(): Promise<void> {
72
+ if (this.#started) return;
73
+ this.#closed = undefined;
74
+ this.#closing = false;
75
+ const publication = plan(this.#options);
76
+ let module: { default?: BonjourConstructor } | BonjourConstructor;
77
+ try {
78
+ module = await import("bonjour-service") as unknown as { default?: BonjourConstructor } | BonjourConstructor;
79
+ } catch {
80
+ throw new Error("DNS-SD discovery is unavailable. Check the bonjour-service installation.");
81
+ }
82
+ const Constructor = typeof module === "function" ? module : module.default;
83
+ if (Constructor === undefined) throw new Error("bonjour-service does not export a constructor.");
84
+ const bonjour = new Constructor({}, (error: unknown) => this.#fail(error));
85
+ try {
86
+ const browser = bonjour.find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL });
87
+ browser.on("up", service => this.#consider(service));
88
+ browser.on("srv-update", service => this.#consider(service));
89
+ browser.on("txt-update", service => this.#consider(service));
90
+ browser.on("down", service => this.#withdraw(service));
91
+ this.#bonjour = bonjour;
92
+ this.#browser = browser;
93
+
94
+ // A probe keeps the first `records()` call asynchronous, which is what lets
95
+ // the loopback filter below take effect before the announcement is sent.
96
+ const service = bonjour.publish({
97
+ name: publication.name,
98
+ type: SERVICE_TYPE,
99
+ protocol: SERVICE_PROTOCOL,
100
+ port: publication.port,
101
+ host: publication.host,
102
+ probe: true,
103
+ txt: publication.txt,
104
+ });
105
+ if (isLoopback(publication.host)) {
106
+ // bonjour-service attaches every interface address to the SRV target. A
107
+ // loopback binding is reachable only from this host, so publishing those
108
+ // addresses (none of them loopback) would advertise a dead endpoint.
109
+ const records = service.records.bind(service);
110
+ service.records = () => records().filter(record => record.type !== "A" && record.type !== "AAAA");
111
+ }
112
+ this.#service = service;
113
+ this.#started = true;
114
+ } catch (error) {
115
+ bonjour.destroy(() => undefined);
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async close(): Promise<void> {
121
+ if (this.#closed !== undefined) return this.#closed;
122
+ const bonjour = this.#bonjour;
123
+ this.#closing = true;
124
+ this.#started = false;
125
+ this.#browser?.stop();
126
+ this.#browser = undefined;
127
+ this.#service = undefined;
128
+ this.#bonjour = undefined;
129
+ this.#inflight.clear();
130
+ this.#delivered.clear();
131
+ if (bonjour === undefined) {
132
+ this.#closed = Promise.resolve();
133
+ return this.#closed;
134
+ }
135
+ this.#closed = new Promise<void>(resolve => {
136
+ bonjour.unpublishAll(() => bonjour.destroy(() => resolve()));
137
+ });
138
+ return this.#closed;
139
+ }
140
+
141
+ #consider(service: BonjourService): void {
142
+ if (this.#closing) return;
143
+ if (service.fqdn === this.#service?.fqdn) return;
144
+ const txt = text(service);
145
+ const node = txt["node_id"];
146
+ const instance = txt["instance_id"];
147
+ if (node === this.#options.node_id && instance === this.#options.instance_id) return;
148
+
149
+ const protocol = txt["protocol"];
150
+ if (protocol !== WIRE_PROTOCOL) {
151
+ this.#fail(new Error(`Service ${service.name} does not speak ${WIRE_PROTOCOL}.`));
152
+ return;
153
+ }
154
+ if (node === undefined || instance === undefined) {
155
+ this.#fail(new Error(`Service ${service.name} is missing its node_id or instance_id.`));
156
+ return;
157
+ }
158
+ const scheme = txt["scheme"];
159
+ if (scheme !== "http" && scheme !== "https") {
160
+ this.#fail(new Error(`Service ${service.name} advertises scheme ${scheme ?? "(missing)"}.`));
161
+ return;
162
+ }
163
+ const path = txt["path"];
164
+ if (path === undefined || !normalizePath(path).endsWith(SERVICE_PATH_SUFFIX)) {
165
+ this.#fail(new Error(`Service ${service.name} does not advertise a ${SERVICE_PATH_SUFFIX} path.`));
166
+ return;
167
+ }
168
+ if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65_535) {
169
+ this.#fail(new Error(`Service ${service.name} advertises port ${String(service.port)}.`));
170
+ return;
171
+ }
172
+
173
+ const host = candidateHost(service);
174
+ if (host === undefined) {
175
+ this.#fail(new Error(`Service ${service.name} advertises no address reachable from this host.`));
176
+ return;
177
+ }
178
+ if (!isLoopback(host) && scheme !== "https") {
179
+ this.#fail(new Error(`Service ${service.name} advertises plain HTTP across hosts; Onlyne requires HTTPS there.`));
180
+ return;
181
+ }
182
+
183
+ const url = `${scheme}://${host.includes(":") ? `[${host}]` : host}:${String(service.port)}${normalizePath(path)}`;
184
+ const previous = this.#delivered.get(service.fqdn);
185
+ if (previous === url) return;
186
+ if (previous !== undefined) {
187
+ this.#delivered.delete(service.fqdn);
188
+ this.#options.onUnavailable?.(previous);
189
+ }
190
+ void this.#deliver(service.fqdn, url, node);
191
+ }
192
+
193
+ #deliver(fqdn: string, url: string, expectedNode: string): Promise<boolean> {
194
+ const running = this.#inflight.get(url);
195
+ if (running !== undefined) return running;
196
+ const attempt = this.#options
197
+ .onCandidate(url, SOURCE, expectedNode)
198
+ .then(() => true)
199
+ .catch((error: unknown) => {
200
+ this.#fail(error);
201
+ return false;
202
+ });
203
+ const tracked = attempt.finally(() => {
204
+ if (this.#inflight.get(url) === tracked) this.#inflight.delete(url);
205
+ });
206
+ tracked.then(delivered => {
207
+ if (delivered && !this.#closing && !this.#delivered.has(fqdn)) this.#delivered.set(fqdn, url);
208
+ });
209
+ this.#inflight.set(url, tracked);
210
+ return tracked;
211
+ }
212
+
213
+ #withdraw(service: BonjourService): void {
214
+ const url = this.#delivered.get(service.fqdn);
215
+ if (url === undefined) return;
216
+ this.#delivered.delete(service.fqdn);
217
+ this.#options.onUnavailable?.(url);
218
+ }
219
+
220
+ #fail(error: unknown): void {
221
+ if (this.#closing) return;
222
+ try {
223
+ this.#options.onError(error);
224
+ } catch {
225
+ // The error sink is the end of the chain; a throwing sink must not kill the mDNS loop.
226
+ }
227
+ }
228
+ }
229
+
230
+ interface Publication {
231
+ name: string;
232
+ host: string;
233
+ port: number;
234
+ txt: Record<string, string>;
235
+ }
236
+
237
+ /** Validate everything the advertisement needs before the socket exists. */
238
+ function plan(options: NetworkDiscoveryOptions): Publication {
239
+ if (options.node_id.length === 0) throw new Error("Onlyne discovery needs a node id.");
240
+ if (options.instance_id.length === 0) throw new Error("Onlyne discovery needs an instance id.");
241
+ let endpoint: URL;
242
+ try {
243
+ endpoint = new URL(options.endpoint);
244
+ } catch {
245
+ throw new Error(`The Onlyne endpoint must be an absolute URL, got ${options.endpoint}.`);
246
+ }
247
+ if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
248
+ throw new Error(`The Onlyne endpoint must use http or https, got ${endpoint.protocol}.`);
249
+ }
250
+ const scheme = endpoint.protocol.slice(0, -1);
251
+ const path = servicePath(endpoint.pathname);
252
+ const port = endpoint.port === "" ? (scheme === "https" ? 443 : 80) : Number(endpoint.port);
253
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
254
+ throw new Error(`The Onlyne endpoint must name the listening port, got ${options.endpoint}.`);
255
+ }
256
+ const advertised = normalizeHost(endpoint.hostname);
257
+ if (advertised === undefined) throw new Error(`The Onlyne endpoint has no host, got ${options.endpoint}.`);
258
+ // A wildcard binding is not a connectable address; publish the interface it
259
+ // listens on instead, and never the wildcard itself.
260
+ const host = isWildcard(advertised) ? interfaceAddress(options.bind) : advertised;
261
+ return {
262
+ name: options.instance_id,
263
+ host,
264
+ port,
265
+ txt: {
266
+ protocol: WIRE_PROTOCOL,
267
+ path,
268
+ scheme,
269
+ node_id: options.node_id,
270
+ instance_id: options.instance_id,
271
+ },
272
+ };
273
+ }
274
+
275
+ /** The advertised address of a wildcard binding: its bind when concrete, else a live interface. */
276
+ function interfaceAddress(bind: string): string {
277
+ const concrete = normalizeHost(bind);
278
+ if (concrete !== undefined && !isWildcard(concrete)) return concrete;
279
+ const ipv4: string[] = [];
280
+ const ipv6: string[] = [];
281
+ for (const entries of Object.values(networkInterfaces())) {
282
+ for (const entry of entries ?? []) {
283
+ if (entry.internal) continue;
284
+ const address = normalizeAddress(entry.address);
285
+ if (address === undefined || address.startsWith("fe80:") || address.startsWith("169.254.")) continue;
286
+ (address.includes(":") ? ipv6 : ipv4).push(address);
287
+ }
288
+ }
289
+ const chosen = ipv4[0] ?? ipv6[0];
290
+ if (chosen === undefined) {
291
+ throw new Error("No interface address can be advertised; set server.bind to a concrete address.");
292
+ }
293
+ return chosen;
294
+ }
295
+
296
+ /**
297
+ * The address this host can use to reach the advertised service. Addresses that
298
+ * this host already owns are only applicable when the packet came from this
299
+ * host: a remote service cannot own them, and a remote loopback is unreachable.
300
+ */
301
+ function candidateHost(service: BonjourService): string | undefined {
302
+ const local = localAddresses();
303
+ const referer = normalizeHost(service.referer?.address);
304
+ const originLocal = referer !== undefined && (isLoopback(referer) || local.has(referer));
305
+ for (const raw of service.addresses ?? []) {
306
+ const address = normalizeAddress(raw);
307
+ if (address === undefined || isWildcard(address)) continue;
308
+ if (address.startsWith("fe80:") || address.startsWith("169.254.")) continue;
309
+ if (address.startsWith("ff") || /^(22[4-9]|23\d)\./.test(address)) continue;
310
+ if (isLoopback(address)) {
311
+ if (originLocal) return address;
312
+ continue;
313
+ }
314
+ if (local.has(address) && !originLocal) continue;
315
+ return address;
316
+ }
317
+ const target = normalizeHost(service.host);
318
+ if (target === undefined || isWildcard(target)) return undefined;
319
+ if (isLoopback(target)) return originLocal ? target : undefined;
320
+ return target;
321
+ }
322
+
323
+ function localAddresses(): Set<string> {
324
+ const hosts = new Set<string>(["127.0.0.1", "::1", "localhost"]);
325
+ for (const entries of Object.values(networkInterfaces())) {
326
+ for (const entry of entries ?? []) {
327
+ const address = normalizeAddress(entry.address);
328
+ if (address !== undefined) hosts.add(address);
329
+ }
330
+ }
331
+ return hosts;
332
+ }
333
+
334
+ function text(service: BonjourService): Record<string, string> {
335
+ const fields: Record<string, string> = {};
336
+ const raw: unknown = service.txt;
337
+ if (raw === null || typeof raw !== "object") return fields;
338
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
339
+ if (typeof value === "string") fields[key] = value;
340
+ else if (Array.isArray(value) && typeof value[0] === "string") fields[key] = value[0];
341
+ }
342
+ return fields;
343
+ }
344
+
345
+ function servicePath(pathname: string): string {
346
+ const path = normalizePath(pathname);
347
+ if (!path.endsWith(SERVICE_PATH_SUFFIX)) {
348
+ throw new Error(`The Onlyne endpoint path must end with ${SERVICE_PATH_SUFFIX}, got ${pathname}.`);
349
+ }
350
+ return path;
351
+ }
352
+
353
+ function normalizePath(pathname: string): string {
354
+ const absolute = pathname.startsWith("/") ? pathname : `/${pathname}`;
355
+ const trimmed = absolute.replace(/\/+$/, "");
356
+ return trimmed === "" ? "/" : trimmed;
357
+ }
358
+
359
+ function normalizeAddress(value: string): string | undefined {
360
+ let address = value.trim();
361
+ if (address.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
362
+ const zone = address.indexOf("%");
363
+ if (zone >= 0) address = address.slice(0, zone);
364
+ return address.length === 0 ? undefined : address.toLowerCase();
365
+ }
366
+
367
+ function normalizeHost(value: string | undefined): string | undefined {
368
+ if (value === undefined) return undefined;
369
+ const host = normalizeAddress(value);
370
+ if (host === undefined) return undefined;
371
+ return host.endsWith(".") ? host.slice(0, -1) : host;
372
+ }
373
+
374
+ function isLoopback(host: string): boolean {
375
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1"
376
+ || host === "0:0:0:0:0:0:0:1" || host.startsWith("127.");
377
+ }
378
+
379
+ function isWildcard(host: string): boolean {
380
+ return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0";
381
+ }
382
+
@@ -0,0 +1,73 @@
1
+ import { createTranslator } from './i18n.ts';
2
+
3
+ const en = {
4
+ 'title': 'Onlyne gateway · {actor} · {endpoint}',
5
+ 'peers.none': 'No nodes discovered yet.',
6
+ 'peers.count': 'Nodes ({count})',
7
+ 'column.delivery': 'deliver',
8
+ 'hint.idle': 'Press [c] to enter an address, or wait for a peer to appear over mDNS.',
9
+ 'label.fingerprint': 'fingerprint',
10
+ 'label.endpoint': 'endpoint',
11
+ 'label.delivery': 'delivery',
12
+ 'value.notExchanged': 'not exchanged',
13
+ 'value.unknown': 'unknown',
14
+ 'confirm.title': 'Confirm the fingerprint of {node}?',
15
+ 'confirm.keys': ' [y] confirm [n] cancel',
16
+ 'input.prompt': 'address> {value}▏',
17
+ 'help.keys': '[t] request exchange [a] accept request [r] revoke [c] connect address [q] quit',
18
+ 'notice.cancelled': 'Cancelled.',
19
+ 'notice.enterAddress': 'Enter a node service address, then press Enter to connect; Esc cancels.',
20
+ 'notice.refreshFailed': 'Refresh failed: {error}',
21
+ 'notice.alreadyExchanged': '{node} has already exchanged keys.',
22
+ 'notice.noRequest': '{node} has no request waiting for your confirmation.',
23
+ 'notice.accepted': 'Approved the key of {node}.',
24
+ 'notice.noTrust': '{node} has no trust record.',
25
+ 'notice.revoked': 'Revoked trust for {node}.',
26
+ 'notice.noAddress': 'No address entered.',
27
+ 'notice.connected': 'Connected to {node}.',
28
+ 'notice.submitted': '{node} submitted, current stage {phase}.',
29
+ 'notice.operationFailed': 'Operation failed: {error}',
30
+ 'phase.exchanged': 'verified',
31
+ 'phase.requested': 'awaiting peer',
32
+ 'phase.invited': 'needs approval',
33
+ 'phase.revoked': 'revoked',
34
+ 'list.separator': ', ',
35
+ } as const;
36
+
37
+ const zh: Record<keyof typeof en, string> = {
38
+ 'title': 'Onlyne 网关 · {actor} · {endpoint}',
39
+ 'peers.none': '还没有发现任何节点。',
40
+ 'peers.count': '节点 ({count})',
41
+ 'column.delivery': '投送',
42
+ 'hint.idle': '用 [c] 输入一个地址,或用 mDNS 等对端出现。',
43
+ 'label.fingerprint': '指纹',
44
+ 'label.endpoint': '端点',
45
+ 'label.delivery': '投送',
46
+ 'value.notExchanged': '未交换',
47
+ 'value.unknown': '未知',
48
+ 'confirm.title': '确认 {node} 的指纹?',
49
+ 'confirm.keys': ' [y] 确认 [n] 取消',
50
+ 'input.prompt': '地址> {value}▏',
51
+ 'help.keys': '[t] 请求交换 [a] 接受申请 [r] 撤销 [c] 连接地址 [q] 退出界面',
52
+ 'notice.cancelled': '已取消。',
53
+ 'notice.enterAddress': '输入节点服务地址,回车连接,Esc 取消。',
54
+ 'notice.refreshFailed': '刷新失败:{error}',
55
+ 'notice.alreadyExchanged': '{node} 已经完成交换。',
56
+ 'notice.noRequest': '{node} 没有等待你确认的申请。',
57
+ 'notice.accepted': '已批准 {node} 的密钥。',
58
+ 'notice.noTrust': '{node} 没有信任记录。',
59
+ 'notice.revoked': '已撤销 {node} 的信任。',
60
+ 'notice.noAddress': '没有输入地址。',
61
+ 'notice.connected': '已连接 {node}。',
62
+ 'notice.submitted': '{node} 已提交,当前阶段 {phase}。',
63
+ 'notice.operationFailed': '操作失败:{error}',
64
+ 'phase.exchanged': '已认证',
65
+ 'phase.requested': '等对方',
66
+ 'phase.invited': '待确认',
67
+ 'phase.revoked': '已撤销',
68
+ 'list.separator': '、',
69
+ };
70
+
71
+ export type TuiTextKey = keyof typeof en;
72
+
73
+ export const t = createTranslator({ en, 'zh-CN': zh });
package/src/tui.ts ADDED
@@ -0,0 +1,329 @@
1
+ import { trustPhase, type KeyExchange, type OnlyneNode, type TrustPhase } from '@onlyne/lib';
2
+ import type { LogRing } from './log.ts';
3
+ import { t, type TuiTextKey } from './tui-text.ts';
4
+
5
+ /** One peer as the terminal shows it: a discovery address plus whatever the trust table knows. */
6
+ interface Row {
7
+ node: string;
8
+ endpoint?: string;
9
+ phase?: TrustPhase;
10
+ fingerprint?: string;
11
+ /** Resources this gateway serves whose binding points at this node: where events go now. */
12
+ delivery?: string[];
13
+ }
14
+
15
+ const PHASE_KEY: Record<TrustPhase, TuiTextKey> = {
16
+ exchanged: 'phase.exchanged',
17
+ requested: 'phase.requested',
18
+ invited: 'phase.invited',
19
+ revoked: 'phase.revoked',
20
+ };
21
+
22
+ const NO_PHASE = '—';
23
+ const SHORT = (node: string): string => node.split(':').pop() ?? node;
24
+
25
+ function failureText(error: unknown): string {
26
+ if (error instanceof Error && 'error' in error) {
27
+ const inner = (error as { error?: { message?: string } }).error;
28
+ if (typeof inner?.message === 'string') return inner.message;
29
+ }
30
+ return String(error);
31
+ }
32
+
33
+ type Mode = { kind: 'list' } | { kind: 'confirm'; exchange: KeyExchange } | { kind: 'input' };
34
+
35
+ /**
36
+ * The gateway's foreground surface: one screen for the peers it discovered, the trust stage
37
+ * of each, and the four actions that change one. Every confirmation shows the fingerprint
38
+ * first, because that string — not the node id — is what the two operators compare.
39
+ */
40
+ export class GatewayTui {
41
+ #rows: Row[] = [];
42
+ #index = 0;
43
+ #mode: Mode = { kind: 'list' };
44
+ #input = '';
45
+ #message = '';
46
+ #busy = false;
47
+ #closed = false;
48
+ #refreshTimer?: ReturnType<typeof setInterval>;
49
+ #statusLines: string[] = [];
50
+
51
+ constructor(
52
+ private readonly node: OnlyneNode,
53
+ private readonly ring: LogRing,
54
+ private readonly endpoint: string,
55
+ private readonly onChange: () => void = () => undefined,
56
+ private readonly status?: () => Promise<string[]>,
57
+ ) {}
58
+
59
+ async start(): Promise<void> {
60
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
61
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
62
+ process.stdin.resume();
63
+ process.stdin.setEncoding('utf8');
64
+ process.stdin.on('data', this.#onData);
65
+ process.stdout.on('resize', this.#onResize);
66
+ await this.#refresh();
67
+ this.#refreshTimer = setInterval(() => { void this.#refresh(); }, 1000);
68
+ }
69
+
70
+ async close(): Promise<void> {
71
+ if (this.#closed) return;
72
+ this.#closed = true;
73
+ clearInterval(this.#refreshTimer);
74
+ process.stdin.off('data', this.#onData);
75
+ process.stdout.off('resize', this.#onResize);
76
+ process.stdin.pause();
77
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
78
+ process.stdout.write('\x1b[?25h\x1b[?1049l');
79
+ }
80
+
81
+ #onResize = (): void => { this.#draw(); };
82
+
83
+ #onData = (chunk: string): void => {
84
+ if (this.#closed) return;
85
+ // One `data` event can carry a whole paste or a burst of keystrokes, so it is split into
86
+ // keys rather than treated as one. Each key runs in order; `#busy` still gates actions.
87
+ for (const token of tokens(chunk)) void this.#handle(token);
88
+ };
89
+
90
+ async #handle(token: Token): Promise<void> {
91
+ if (this.#busy) return;
92
+ if (this.#mode.kind === 'confirm') {
93
+ if (token === 'y') await this.#settle(true);
94
+ else if (token === 'n' || token === 'esc') { this.#mode = { kind: 'list' }; this.#message = t('notice.cancelled'); this.#draw(); }
95
+ return;
96
+ }
97
+ if (this.#mode.kind === 'input') {
98
+ if (token === 'esc') { this.#mode = { kind: 'list' }; this.#input = ''; this.#draw(); return; }
99
+ if (token === 'enter') { await this.#connect(); return; }
100
+ if (token === 'backspace') { this.#input = this.#input.slice(0, -1); this.#draw(); return; }
101
+ if (typeof token === 'string' && token.length === 1 && token >= ' ') { this.#input += token; this.#draw(); }
102
+ return;
103
+ }
104
+ if (token === 'q') { await this.close(); this.onChange(); return; }
105
+ if (token === 'up') { this.#move(-1); return; }
106
+ if (token === 'down') { this.#move(1); return; }
107
+ if (token === 'c') { this.#mode = { kind: 'input' }; this.#input = ''; this.#message = t('notice.enterAddress'); this.#draw(); return; }
108
+ const row = this.#rows[this.#index];
109
+ if (row === undefined) return;
110
+ if (token === 't') { await this.#begin(row); return; }
111
+ if (token === 'a') { await this.#accept(row); return; }
112
+ if (token === 'r') { await this.#revoke(row); return; }
113
+ }
114
+
115
+ #move(delta: number): void {
116
+ if (this.#rows.length === 0) return;
117
+ this.#index = (this.#index + delta + this.#rows.length) % this.#rows.length;
118
+ this.#draw();
119
+ }
120
+
121
+ async #refresh(): Promise<void> {
122
+ if (this.#closed) return;
123
+ try {
124
+ const [trust, records, status] = await Promise.all([
125
+ this.node.trust.list(this.node.auth),
126
+ this.node.discovery.listRecords(),
127
+ this.status?.() ?? [],
128
+ ]);
129
+ this.#statusLines = status;
130
+ const handlers = new Map<string, string[]>();
131
+ const resources = await this.node.resources.list(this.node.auth, { page_size: 100 });
132
+ for (const resource of resources.items) {
133
+ const list = handlers.get(resource.binding.handler) ?? [];
134
+ list.push(resource.resource_ref.id);
135
+ handlers.set(resource.binding.handler, list);
136
+ }
137
+ const rows = new Map<string, Row>();
138
+ for (const record of records) {
139
+ if (record.node_id === this.node.auth.actor) continue;
140
+ rows.set(record.node_id, { node: record.node_id, endpoint: record.base_url });
141
+ }
142
+ for (const entry of trust) {
143
+ if (entry.node_id === this.node.auth.actor) continue;
144
+ const row = rows.get(entry.node_id) ?? { node: entry.node_id };
145
+ rows.set(entry.node_id, { ...row, phase: trustPhase(entry), fingerprint: entry.fingerprint, endpoint: row.endpoint ?? entry.base_url });
146
+ }
147
+ for (const [handler, ids] of handlers) {
148
+ if (handler === this.node.auth.actor) continue;
149
+ const row = rows.get(handler) ?? { node: handler };
150
+ rows.set(handler, { ...row, delivery: ids });
151
+ }
152
+ this.#rows = [...rows.values()].sort((a, b) => (a.node < b.node ? -1 : a.node > b.node ? 1 : 0));
153
+ if (this.#index >= this.#rows.length) this.#index = Math.max(0, this.#rows.length - 1);
154
+ this.#draw();
155
+ } catch (error) {
156
+ this.#message = t('notice.refreshFailed', { error: failureText(error) });
157
+ this.#draw();
158
+ }
159
+ }
160
+
161
+ async #begin(row: Row): Promise<void> {
162
+ if (row.phase === 'exchanged') { this.#message = t('notice.alreadyExchanged', { node: SHORT(row.node) }); this.#draw(); return; }
163
+ await this.#run(async () => {
164
+ const exchange = await this.node.trust.begin(this.node.auth, row.node);
165
+ this.#mode = { kind: 'confirm', exchange };
166
+ this.#message = '';
167
+ });
168
+ }
169
+
170
+ async #accept(row: Row): Promise<void> {
171
+ if (row.phase !== 'invited' || row.fingerprint === undefined) {
172
+ this.#message = t('notice.noRequest', { node: SHORT(row.node) });
173
+ this.#draw();
174
+ return;
175
+ }
176
+ const fingerprint = row.fingerprint;
177
+ await this.#run(async () => {
178
+ await this.node.trust.accept(this.node.auth, row.node, fingerprint);
179
+ this.#message = t('notice.accepted', { node: SHORT(row.node) });
180
+ });
181
+ }
182
+
183
+ async #revoke(row: Row): Promise<void> {
184
+ if (row.phase === undefined) { this.#message = t('notice.noTrust', { node: SHORT(row.node) }); this.#draw(); return; }
185
+ await this.#run(async () => {
186
+ await this.node.trust.revoke(this.node.auth, row.node);
187
+ this.#message = t('notice.revoked', { node: SHORT(row.node) });
188
+ });
189
+ }
190
+
191
+ async #connect(): Promise<void> {
192
+ const value = this.#input.trim();
193
+ this.#mode = { kind: 'list' };
194
+ this.#input = '';
195
+ if (value === '') { this.#message = t('notice.noAddress'); this.#draw(); return; }
196
+ await this.#run(async () => {
197
+ const url = new URL(value.includes('://') ? value : `http://${value}`);
198
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/onlyne/v3';
199
+ const record = await this.node.discovery.observe(url.href, 'manual');
200
+ this.#message = t('notice.connected', { node: record.node_id });
201
+ });
202
+ }
203
+
204
+ async #settle(accept: boolean): Promise<void> {
205
+ const mode = this.#mode;
206
+ if (mode.kind !== 'confirm') return;
207
+ if (!accept) { this.#mode = { kind: 'list' }; this.#message = t('notice.cancelled'); this.#draw(); return; }
208
+ this.#mode = { kind: 'list' };
209
+ await this.#run(async () => {
210
+ const entry = await mode.exchange.confirm(mode.exchange.fingerprint);
211
+ this.#message = t('notice.submitted', { node: SHORT(entry.node_id), phase: t(PHASE_KEY[trustPhase(entry)]) });
212
+ });
213
+ }
214
+
215
+ async #run(work: () => Promise<void>): Promise<void> {
216
+ this.#busy = true;
217
+ this.#draw();
218
+ try { await work(); }
219
+ catch (error) { this.#message = t('notice.operationFailed', { error: failureText(error) }); }
220
+ finally {
221
+ this.#busy = false;
222
+ await this.#refresh();
223
+ }
224
+ }
225
+
226
+ #draw(): void {
227
+ if (this.#closed) return;
228
+ // One cell short of the terminal width: writing the last cell auto-wraps, which would
229
+ // consume a second row per line and push the frame upward.
230
+ const width = Math.max(48, (process.stdout.columns ?? 80) - 1);
231
+ const height = Math.max(16, process.stdout.rows ?? 24);
232
+ const lines: string[] = [];
233
+ const push = (text: string): void => { lines.push(fit(text, width)); };
234
+
235
+ push(t('title', { actor: this.node.auth.actor, endpoint: this.endpoint }));
236
+ push(rule(width));
237
+
238
+ if (this.#statusLines.length > 0) {
239
+ const budget = Math.max(3, Math.floor(height / 3));
240
+ for (const line of this.#statusLines.slice(0, budget)) push(line);
241
+ if (this.#statusLines.length > budget) push('…');
242
+ push(rule(width));
243
+ }
244
+
245
+ const peerHeader = this.#rows.length === 0 ? t('peers.none') : t('peers.count', { count: this.#rows.length });
246
+ push(peerHeader);
247
+ for (const [index, row] of this.#rows.entries()) {
248
+ const marker = index === this.#index ? '▸' : ' ';
249
+ const phase = row.phase === undefined ? NO_PHASE : t(PHASE_KEY[row.phase]);
250
+ // Delivery is the binding's answer, not the trust stage's: a node can be exchanged and
251
+ // still receive nothing, and the handler can be a node with no key exchange at all.
252
+ const delivery = row.delivery === undefined ? '' : t('column.delivery');
253
+ push(`${marker} ${SHORT(row.node).padEnd(14)} ${phase.padEnd(14)} ${delivery.padEnd(8)} ${row.endpoint ?? ''}`);
254
+ }
255
+
256
+ push(rule(width));
257
+ const selected = this.#rows[this.#index];
258
+ if (selected === undefined) {
259
+ push(t('hint.idle'));
260
+ } else {
261
+ push(selected.node);
262
+ push(`${t('label.fingerprint').padEnd(6)}${selected.fingerprint ?? t('value.notExchanged')}`);
263
+ push(`${t('label.endpoint').padEnd(6)}${selected.endpoint ?? t('value.unknown')}`);
264
+ if (selected.delivery !== undefined) push(`${t('label.delivery').padEnd(6)}${selected.delivery.join(t('list.separator'))}`);
265
+ }
266
+
267
+ push(rule(width));
268
+ if (this.#mode.kind === 'confirm') {
269
+ const exchange = this.#mode.exchange;
270
+ push(t('confirm.title', { node: SHORT(exchange.node_id) }));
271
+ push(` ${exchange.fingerprint}`);
272
+ push(t('confirm.keys'));
273
+ } else if (this.#mode.kind === 'input') {
274
+ push(t('input.prompt', { value: this.#input }));
275
+ } else {
276
+ push(t('help.keys'));
277
+ }
278
+ if (this.#message !== '') push(this.#message);
279
+ if (this.#busy) push('…');
280
+
281
+ const logLines = Math.max(0, height - lines.length - 1);
282
+ if (logLines > 0) {
283
+ push(rule(width));
284
+ for (const line of this.ring.tail(logLines)) push(line);
285
+ }
286
+
287
+ let frame = '\x1b[H';
288
+ for (let index = 0; index < height; index++) {
289
+ frame += `${lines[index] ?? ''}\x1b[K${index === height - 1 ? '' : '\r\n'}`;
290
+ }
291
+ frame += '\x1b[J';
292
+ process.stdout.write(frame);
293
+ }
294
+ }
295
+
296
+ type Token = 'up' | 'down' | 'enter' | 'esc' | 'backspace' | 'y' | 'n' | 'q' | 't' | 'a' | 'r' | 'c' | string;
297
+
298
+ /** Splits one stdin chunk into keys: CSI arrows, a bare escape, and every printable character. */
299
+ function tokens(chunk: string): Token[] {
300
+ const out: Token[] = [];
301
+ let rest = chunk.replace(/\x1b\[20[01]~/g, '');
302
+ while (rest.length > 0) {
303
+ if (rest.startsWith('\x1b[A')) { out.push('up'); rest = rest.slice(3); continue; }
304
+ if (rest.startsWith('\x1b[B')) { out.push('down'); rest = rest.slice(3); continue; }
305
+ if (rest.startsWith('\x1b[')) {
306
+ // Any other control sequence is not a key this surface uses; drop it whole.
307
+ const end = rest.slice(2).search(/[@-~]/);
308
+ rest = end < 0 ? '' : rest.slice(end + 3);
309
+ continue;
310
+ }
311
+ if (rest.startsWith('\x1b')) { out.push('esc'); rest = rest.slice(1); continue; }
312
+ const character = rest[0]!;
313
+ if (character === '\r' || character === '\n') out.push('enter');
314
+ else if (character === '\x7f' || character === '\b') out.push('backspace');
315
+ else if (character === '\x03') out.push('q');
316
+ else if (character >= ' ') out.push(character);
317
+ rest = rest.slice(1);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function fit(text: string, width: number): string {
323
+ if (text.length <= width) return text;
324
+ return `${text.slice(0, width - 1)}…`;
325
+ }
326
+
327
+ function rule(width: number): string {
328
+ return '─'.repeat(width);
329
+ }