omp-onlyne 0.1.0 → 0.3.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/node.ts DELETED
@@ -1,127 +0,0 @@
1
- import { networkInterfaces } from "node:os";
2
- import { FetchTransport, OnlyneError, OnlyneNode, trustPhase, type Action, type AdmissionContext, type Auth, type Json, type KeyHook, type KeyMaterial, type MaintenanceClock, type MsgInput, type Runtime, type SigningTransportOptions, type Storage, type Target } from "@onlyne/lib";
3
- import { loadOrCreateKey, writeKeyMaterial } from "@onlyne/node-host/keys";
4
- import { hostText as t } from "./host-text.ts";
5
- import { canAnswer, type ContextRegistry } from "./store.ts";
6
- import type { OnlyneConfig } from "./types.ts";
7
-
8
- export interface NodeDeps {
9
- config: OnlyneConfig;
10
- endpoint: string;
11
- instanceId: string;
12
- storage: Storage;
13
- contexts: ContextRegistry;
14
- decide(input: MsgInput): Promise<Action>;
15
- runtime: Runtime;
16
- /** The timer primitives of the library's own maintenance loop (RFC 0017 §6). */
17
- clock: MaintenanceClock;
18
- /** The directory the host resolved for this node's identity key; absent keeps the loopback development binding. */
19
- keyDir?: string;
20
- keys?: KeyHook;
21
- }
22
-
23
- export function advertisedHost(bind: string): string {
24
- if (bind !== "0.0.0.0" && bind !== "::") return bind;
25
- for (const addresses of Object.values(networkInterfaces())) {
26
- for (const address of addresses ?? []) {
27
- if (!address.internal && address.family === "IPv4") return address.address;
28
- }
29
- }
30
- throw new OnlyneError("unavailable", t("addressUnavailable"));
31
- }
32
-
33
- export function createOnlyneNode(deps: NodeDeps): OnlyneNode {
34
- const { config } = deps;
35
- if (config.node.key_ref && deps.keys === undefined) throw new OnlyneError("unsupported_delivery", t("keyProvider"));
36
- // Without a key directory the node keeps the loopback development binding (RFC 0001 §1).
37
- if (deps.keys === undefined && deps.keyDir === undefined && !["127.0.0.1", "::1", "localhost"].includes(config.server.bind)) {
38
- throw new OnlyneError("unsupported_delivery", t("loopbackBind"));
39
- }
40
- let node: OnlyneNode;
41
- const authenticate = async (request: Request): Promise<Auth | null> => {
42
- const actor = request.headers.get("Onlyne-Actor");
43
- if (!actor || !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$/.test(actor)) return null;
44
- if (actor === config.node.id) throw new OnlyneError("forbidden", t("managementIdentity"));
45
- return { actor };
46
- };
47
- const resolve = async (id: string): Promise<Target> => {
48
- const target = id === config.node.id
49
- ? { node_id: id, base_url: deps.endpoint }
50
- : await node.discovery.resolve(id);
51
- return { ...target, headers: { "Onlyne-Actor": config.node.id } };
52
- };
53
- /**
54
- * Admission reads the trust projection, never the discovery catalog (design §1.1): a discovered
55
- * peer is a clue (RFC 0017 §4), and `discovery.known` is process-local, so a restart would refuse
56
- * a peer whose exchange already completed.
57
- */
58
- const admitted = async (id: string, context: AdmissionContext): Promise<boolean> => {
59
- if (id === config.node.id) return true;
60
- return await context.trust(id) === "exchanged";
61
- };
62
- const keyDir = deps.keyDir;
63
- const keys: KeyHook | undefined = deps.keys ?? (keyDir === undefined ? undefined : Object.assign(
64
- () => loadOrCreateKey(keyDir, config.node.id),
65
- { replace: (material: KeyMaterial) => writeKeyMaterial(keyDir, material, { replace: true }) },
66
- ));
67
- // The library's own signing transport, built here for one reason: a missing trust row has to
68
- // end at this host's menu. Signing still happens only for a keyed node, and the trust reads
69
- // resolve lazily because the service exists only once the node is built.
70
- const signing: SigningTransportOptions | undefined = keys === undefined ? undefined : {
71
- trust: {
72
- entry: id => node.trust.entry(id),
73
- signRequest: (request, target, raw) => node.trust.signRequest(request, target, raw),
74
- verifyResponse: (target, request, response, body) => node.trust.verifyResponse(target, request, response, body),
75
- },
76
- guidance: details => ({
77
- entry: details.required_action === "confirm_exchange"
78
- ? ["/onlyne", t("guideContacts"), t("guidePending"), t("guideAccept")]
79
- : ["/onlyne", t("guideDiscover"), t("guideRequestExchange")],
80
- }),
81
- };
82
- node = new OnlyneNode({
83
- node_id: config.node.id, base_url: deps.endpoint, instance_id: deps.instanceId,
84
- now: () => Date.now(), id: () => crypto.randomUUID(),
85
- authentication: keys === undefined ? ["none"] : ["signature"], plugins: {
86
- storage: deps.storage, authenticate, resolve, decide: deps.decide, runtime: deps.runtime, maintenanceClock: deps.clock,
87
- ...(keys === undefined ? {} : { keys }),
88
- ...(signing === undefined ? {} : { transport: new FetchTransport(resolve, fetch, signing) }),
89
- ...(config.trust === undefined ? {} : { trust_seeds: config.trust }),
90
- domainCandidates: id => node.discovery.candidates(id),
91
- // The keyless development binding checks no identity (RFC 0001 §1): every declared
92
- // actor may open a thread. A keyed node admits only peers whose exchange completed.
93
- mayAdmit: async (actor, _thread, context) => keys === undefined || await admitted(actor, context),
94
- inboundThread: async (actor, context) => keys === undefined || await admitted(actor, context),
95
- // A context that cannot answer is not a continuation target: publishing one lets a
96
- // sender aim a message at a session that can never reply (caerulea report 4, problem 7).
97
- executionContexts: async () => (await deps.contexts.list()).filter(canAnswer)
98
- .map(record => ({ alias: record.alias, summary: record.summary, state: record.state })) as Json,
99
- mention: async alias => {
100
- const name = alias.replace(/^@/, "");
101
- if (name === "self" || name === config.node.id) return config.node.id;
102
- const matches = (await node.discovery.listRecords()).filter(record => record.node_id === name || record.node_id.split(":").at(-1) === name);
103
- const ids = [...new Set(matches.map(record => record.node_id))];
104
- if (ids.length !== 1) throw new OnlyneError(ids.length ? "ambiguous_target" : "not_found", t("mentionNotFound", { name }));
105
- return ids[0]!;
106
- },
107
- directory: async () => ({ node: config.node.id, region: config.region, endpoint: deps.endpoint, peers: await node.discovery.listRecords(), contexts: await deps.contexts.list() }) as unknown as Json,
108
- // The same rows onlyne://neighbors renders: every observed node with its trust state.
109
- neighbors: async () => {
110
- const records = await node.discovery.listRecords();
111
- const trust = new Map((await node.trust.list(node.auth)).map(entry => [entry.node_id, entry]));
112
- return records.map(record => {
113
- const entry = trust.get(record.node_id);
114
- return {
115
- node_id: record.node_id, base_url: record.base_url,
116
- ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
117
- ...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
118
- verified: record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now(),
119
- phase: entry === undefined ? "no_exchange" : trustPhase(entry),
120
- fingerprint: entry?.fingerprint ?? null,
121
- };
122
- });
123
- },
124
- },
125
- });
126
- return node;
127
- }
package/src/store.ts DELETED
@@ -1,189 +0,0 @@
1
- import { Database } from "bun:sqlite";
2
- import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import type { Entry, Storage, ThreadRef, Transaction } from "@onlyne/lib";
5
- import type { ContextRecord } from "./types.ts";
6
- import { bridgeText as t } from "./bridge-text.ts";
7
-
8
- /** Key/value rows under one SQLite file; the library's durable state and the plugin's registry share it. */
9
- export class SqliteStorage implements Storage {
10
- readonly guarantee = "durable" as const;
11
- readonly #db: Database;
12
- #tail: Promise<unknown> = Promise.resolve();
13
- #closed = false;
14
-
15
- constructor(path: string) {
16
- // The store carries trust rows and, while a rotation is unacknowledged, the old private
17
- // key that proves it. It is secret material: 0700 for the directory, 0600 for the files,
18
- // including the WAL and shared-memory sidecars SQLite creates beside them.
19
- const dir = dirname(path);
20
- mkdirSync(dir, { recursive: true, mode: 0o700 });
21
- chmodSync(dir, 0o700);
22
- this.#db = new Database(path, { create: true });
23
- this.#db.exec("PRAGMA journal_mode = WAL");
24
- this.#db.exec("PRAGMA synchronous = FULL");
25
- this.#db.exec("PRAGMA busy_timeout = 5000");
26
- this.#db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
27
- for (const file of [path, `${path}-wal`, `${path}-shm`]) {
28
- if (existsSync(file)) chmodSync(file, 0o600);
29
- }
30
- }
31
-
32
- /** Serialize every transaction; SQLite's own BEGIN IMMEDIATE would otherwise race across awaits. */
33
- transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
34
- const run = () => this.#execute(work);
35
- const result = this.#tail.then(run, run);
36
- this.#tail = result.then(
37
- () => undefined,
38
- () => undefined,
39
- );
40
- return result;
41
- }
42
-
43
- async #execute<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
44
- if (this.#closed) throw new Error(t("storeClosed"));
45
- this.#db.exec("BEGIN IMMEDIATE");
46
- const tx: Transaction = {
47
- get: async <V>(key: string) => this.#get<V>(key),
48
- put: async <V>(key: string, value: V) => this.#put(key, value),
49
- delete: async (key: string) => {
50
- this.#db.query("DELETE FROM kv WHERE key = ?").run(key);
51
- },
52
- scan: async <V>(prefix: string) => this.#scan<V>(prefix),
53
- };
54
- try {
55
- const value = await work(tx);
56
- this.#db.exec("COMMIT");
57
- return value;
58
- } catch (error) {
59
- try {
60
- this.#db.exec("ROLLBACK");
61
- } catch {
62
- // The transaction may already be gone; preserve the original failure.
63
- }
64
- throw error;
65
- }
66
- }
67
-
68
- #get<T>(key: string): T | undefined {
69
- const row = this.#db.query<{ value: string }, [string]>("SELECT value FROM kv WHERE key = ?").get(key);
70
- return row === null ? undefined : (JSON.parse(row.value) as T);
71
- }
72
-
73
- #put(key: string, value: unknown): void {
74
- this.#db
75
- .query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
76
- .run(key, JSON.stringify(value));
77
- }
78
-
79
- #scan<T>(prefix: string): Array<{ key: string; value: T }> {
80
- const rows = this.#db
81
- .query<{ key: string; value: string }, [number, string]>(
82
- "SELECT key, value FROM kv WHERE substr(key, 1, ?) = ? ORDER BY key",
83
- )
84
- .all(prefix.length, prefix);
85
- return rows.map(row => ({ key: row.key, value: JSON.parse(row.value) as T }));
86
- }
87
-
88
- close(): void {
89
- if (this.#closed) return;
90
- this.#closed = true;
91
- this.#db.close();
92
- }
93
- }
94
-
95
- const COUNTER_KEY = "meta/context-counter";
96
- const CONTEXT_PREFIX = "context/";
97
-
98
- /**
99
- * Whether a registered context can answer a delivery. Only the session holding this node's
100
- * own send right can: a subagent context reads and drafts, never sends. Advertising one as a
101
- * continuation target hands the sender a message nobody there can reply to, and the sender
102
- * has no way to see that from the alias (caerulea report 4, problem 7).
103
- */
104
- export function canAnswer(record: ContextRecord): boolean {
105
- return record.agent_id !== undefined && record.agent_id === record.main_agent_id;
106
- }
107
-
108
- /** Alias allocation and binding for execution contexts; the store is the authority across restarts. */
109
- export class ContextRegistry {
110
- constructor(private readonly storage: Storage, private readonly nodeId: string, private readonly mainAgentId: string) {}
111
-
112
- /** The main OMP session is addressable like a registered execution context. */
113
- async ensureMain(): Promise<ContextRecord> {
114
- return this.storage.transaction(async tx => {
115
- const key = `${CONTEXT_PREFIX}main`;
116
- const current = await tx.get<ContextRecord>(key);
117
- if (current !== undefined) return current;
118
- const record: ContextRecord = {
119
- alias: "main", node_id: this.nodeId, main_agent_id: this.mainAgentId, agent_id: this.mainAgentId,
120
- summary: t("mainSummary"), state: "idle", created_at: new Date().toISOString(),
121
- };
122
- await tx.put(key, record);
123
- return record;
124
- });
125
- }
126
-
127
- async allocate(thread_ref: ThreadRef | undefined, summary: string, source?: Entry["message"]): Promise<ContextRecord> {
128
- return this.storage.transaction(async tx => {
129
- const counter = (await tx.get<number>(COUNTER_KEY)) ?? 0;
130
- const next = counter + 1;
131
- await tx.put(COUNTER_KEY, next);
132
- const alias = `c-${String(next)}`;
133
- const record: ContextRecord = {
134
- alias,
135
- node_id: this.nodeId,
136
- main_agent_id: this.mainAgentId,
137
- ...(source === undefined ? {} : { source: { sender: source.sender, id: source.id } }),
138
- ...(thread_ref === undefined ? {} : { thread_ref }),
139
- summary,
140
- state: "unbound",
141
- created_at: new Date().toISOString(),
142
- };
143
- await tx.put(`${CONTEXT_PREFIX}${alias}`, record);
144
- return record;
145
- });
146
- }
147
-
148
- async bind(
149
- alias: string,
150
- binding: { agent_id: string; session_file?: string; parent_id?: string },
151
- ): Promise<ContextRecord> {
152
- return this.storage.transaction(async tx => {
153
- const key = `${CONTEXT_PREFIX}${alias}`;
154
- const current = await tx.get<ContextRecord>(key);
155
- if (current === undefined) throw new Error(t("contextNotRegistered", { alias }));
156
- const next: ContextRecord = {
157
- ...current,
158
- agent_id: binding.agent_id,
159
- ...(binding.session_file === undefined ? {} : { session_file: binding.session_file }),
160
- ...(binding.parent_id === undefined ? {} : { parent_id: binding.parent_id }),
161
- state: current.agent_id === binding.agent_id ? current.state : "idle",
162
- bound_at: new Date().toISOString(),
163
- };
164
- await tx.put(key, next);
165
- return next;
166
- });
167
- }
168
-
169
- async find(alias: string): Promise<ContextRecord | undefined> {
170
- return this.storage.transaction(tx => tx.get<ContextRecord>(`${CONTEXT_PREFIX}${alias}`));
171
- }
172
-
173
- async list(): Promise<ContextRecord[]> {
174
- return this.storage.transaction(async tx =>
175
- (await tx.scan<ContextRecord>(CONTEXT_PREFIX)).map(row => row.value),
176
- );
177
- }
178
-
179
- async updateState(alias: string, state: ContextRecord["state"]): Promise<ContextRecord | undefined> {
180
- return this.storage.transaction(async tx => {
181
- const key = `${CONTEXT_PREFIX}${alias}`;
182
- const current = await tx.get<ContextRecord>(key);
183
- if (current === undefined) return undefined;
184
- const next = { ...current, state };
185
- await tx.put(key, next);
186
- return next;
187
- });
188
- }
189
- }