@onlyne/node-host 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/acquire.d.ts +62 -0
  2. package/dist/acquire.d.ts.map +1 -0
  3. package/dist/acquire.js +146 -0
  4. package/dist/acquire.js.map +1 -0
  5. package/dist/host-text.d.ts +2 -0
  6. package/dist/host-text.d.ts.map +1 -0
  7. package/dist/host-text.js +103 -0
  8. package/dist/host-text.js.map +1 -0
  9. package/dist/i18n.d.ts +9 -0
  10. package/dist/i18n.d.ts.map +1 -0
  11. package/dist/i18n.js +19 -0
  12. package/dist/i18n.js.map +1 -0
  13. package/dist/index.d.ts +29 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +74 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/keys.d.ts +20 -0
  18. package/dist/keys.d.ts.map +1 -0
  19. package/dist/keys.js +144 -0
  20. package/dist/keys.js.map +1 -0
  21. package/dist/log.d.ts +43 -0
  22. package/dist/log.d.ts.map +1 -0
  23. package/dist/log.js +77 -0
  24. package/dist/log.js.map +1 -0
  25. package/dist/network.d.ts +27 -0
  26. package/dist/network.d.ts.map +1 -0
  27. package/dist/network.js +350 -0
  28. package/dist/network.js.map +1 -0
  29. package/dist/node.d.ts +20 -0
  30. package/dist/node.d.ts.map +1 -0
  31. package/dist/node.js +153 -0
  32. package/dist/node.js.map +1 -0
  33. package/dist/store.d.ts +44 -0
  34. package/dist/store.d.ts.map +1 -0
  35. package/dist/store.js +219 -0
  36. package/dist/store.js.map +1 -0
  37. package/dist/tui-text.d.ts +37 -0
  38. package/dist/tui-text.d.ts.map +1 -0
  39. package/dist/tui-text.js +69 -0
  40. package/dist/tui-text.js.map +1 -0
  41. package/dist/tui.d.ts +19 -0
  42. package/dist/tui.d.ts.map +1 -0
  43. package/dist/tui.js +400 -0
  44. package/dist/tui.js.map +1 -0
  45. package/dist/types.d.ts +62 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +2 -0
  48. package/dist/types.js.map +1 -0
  49. package/package.json +46 -10
  50. package/src/i18n.ts +0 -22
  51. package/src/index.ts +0 -82
  52. package/src/keys.ts +0 -158
  53. package/src/log.ts +0 -103
  54. package/src/network.ts +0 -382
  55. package/src/tui-text.ts +0 -73
  56. package/src/tui.ts +0 -329
package/src/index.ts DELETED
@@ -1,82 +0,0 @@
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 DELETED
@@ -1,158 +0,0 @@
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 DELETED
@@ -1,103 +0,0 @@
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
- }