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/runtime.ts CHANGED
@@ -1,18 +1,33 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { dirname, join } from "node:path";
2
- import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page } from "@onlyne/lib";
3
+ import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page, type TrustEntry } from "@onlyne/lib";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
4
5
  import { ensureKeyDir } from "@onlyne/node-host/keys";
5
6
  import { NetworkDiscovery } from "@onlyne/node-host/network";
6
7
  import { resolveLocale, setLocale } from "@onlyne/node-host/i18n";
8
+ import { acquisitionOf, evaluateAcquisition, type AcquireInput, type AcquireVerdict } from "@onlyne/node-host/acquire";
9
+ import { AcquisitionLog, approvePeer, beginPeer, handshakeEntry, isTrustThread, judgeApproval, judgementFacts, revokePeer, trustThread, TRUST_THREAD, type ApprovalDeps, type ApprovalJudgement, type ApprovalRequest } from "./handshake.ts";
7
10
  import { commandText as t } from "./ui/command-text.ts";
8
11
  import { registerMsgTool } from "./tool.ts";
9
12
  import { currentDecision, JudgeDecider } from "./decider.ts";
10
13
  import { loadConfig, normalizeServiceUrl, saveConfig, shortNodeId } from "./config.ts";
11
14
  import { createRuntimeBridge } from "./messages.ts";
12
- import { advertisedHost, createOnlyneNode } from "./node.ts";
13
- import { canAnswer, ContextRegistry, SqliteStorage } from "./store.ts";
15
+ import { advertisedHost, createOnlyneNode } from "@onlyne/node-host/node";
16
+ import { canAnswer, ContextRegistry, SqliteStorage } from "@onlyne/node-host/store";
14
17
  import { resourceQuery } from "./resources.ts";
15
- import type { OnlyneConfig, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "./types.ts";
18
+ import type { OnlyneConfig } from "@onlyne/node-host/types";
19
+ import type { AcquisitionAuditEntry, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread, ViewerTrustRow } from "./types.ts";
20
+
21
+ /** The trust transition of RFC 0017 §5, as the host receives it. */
22
+ type TrustNotify = Extract<NotifyEvent, { event: "trust" }>;
23
+
24
+ /** Which actor a judgement in flight belongs to: the agent that asked, or the policy approving on its own. */
25
+ interface AuditScope {
26
+ actor: AcquisitionAuditEntry["actor"];
27
+ action: string;
28
+ /** The judgement this scope produced, kept because a refusal throws away the value the caller sees. */
29
+ capture?: (judgement: ApprovalJudgement) => void;
30
+ }
16
31
 
17
32
  export interface PluginRuntime {
18
33
  readonly config: OnlyneConfig;
@@ -32,9 +47,16 @@ export interface PluginRuntime {
32
47
  contact(endpoint: string): Promise<void>;
33
48
  /** Stage one of one exchange; the returned session carries the fingerprint the peer proved. */
34
49
  trustBegin(node: string): Promise<KeyExchange>;
50
+ /** A human's confirmation in the panel: RFC 0018 §2 keeps that gate outside the policy. */
35
51
  trustAccept(node: string, fingerprint: string): Promise<void>;
36
52
  trustRevoke(node: string): Promise<void>;
37
53
  trustRotate(): Promise<{ fingerprint: string }>;
54
+ /** RFC 0018 §2/§3.1: an agent's approval, judged against the acquisition policy first. */
55
+ acquireAccept(node: string, fingerprint: string): Promise<TrustEntry>;
56
+ /** RFC 0018 §2/§3.1: an agent's revocation. The policy never applies to dropping a peer. */
57
+ acquireRevoke(node: string): Promise<void>;
58
+ /** RFC 0018 §2/§3.1: an agent's initiation, judged before the key read that would begin it. */
59
+ acquireBegin(node: string): Promise<TrustEntry>;
38
60
  refreshDiscovery(): Promise<void>;
39
61
  updateConfig(next: OnlyneConfig): Promise<void>;
40
62
  close(): Promise<void>;
@@ -61,11 +83,45 @@ export async function restartRuntime(config: OnlyneConfig, pi: ExtensionAPI, ctx
61
83
  registerMsgTool(pi);
62
84
  return runtime;
63
85
  }
64
- export async function stopRuntime(): Promise<void> { await active?.close(); }
86
+ export async function stopRuntime(): Promise<void> {
87
+ const closing = active;
88
+ active = undefined;
89
+ await closing?.close();
90
+ }
65
91
 
66
92
  /** How often the discovery catalog is re-observed; well inside the library's 120 s freshness window. */
67
93
  const DISCOVERY_REFRESH_MS = 30_000;
68
94
 
95
+ /**
96
+ * The discovery layer's loopback switch. `createOnlyneNode` passes no `plugins.allow_loopback`,
97
+ * so this host always runs the documented default; the panel reports it beside the policy.
98
+ */
99
+ const ALLOW_LOOPBACK = false;
100
+ /** Audit rows kept in the store; the panel renders the newest 20, pruning keeps the file bounded. */
101
+ const AUDIT_PREFIX = "acquisition/audit/";
102
+ const AUDIT_KEEP = 64;
103
+ const AUDIT_VISIBLE = 20;
104
+
105
+ /** The alias the context registry gives this node's main session (`ContextRegistry.ensureMain`). */
106
+ const MAIN_CONTEXT = "main";
107
+
108
+ /** One mutation's identity for the library: a fresh key and a window that outlives this call. */
109
+ function mutation(): { idempotency_key: string; retry_until: string } {
110
+ return { idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() };
111
+ }
112
+
113
+ /** The port a configured public_url names, defaulting by scheme; undefined when it names none. */
114
+ function publicPort(value: string | undefined): number | undefined {
115
+ if (value === undefined) return undefined;
116
+ try {
117
+ const url = new URL(value);
118
+ if (url.port !== "") return Number(url.port);
119
+ return url.protocol === "https:" ? 443 : 80;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
69
125
  class OmpRuntime implements PluginRuntime {
70
126
  readonly contexts: ContextRegistry;
71
127
  readonly storage: SqliteStorage;
@@ -73,6 +129,15 @@ class OmpRuntime implements PluginRuntime {
73
129
  node!: OnlyneNode;
74
130
  decider!: JudgeDecider;
75
131
  endpoint = "";
132
+ /** RFC 0018 §2.1: where the connection being served came from, for the library's own call into `notify`. */
133
+ private readonly connection = new AsyncLocalStorage<string | undefined>();
134
+ /** RFC 0018 §5: the actor a judgement in flight belongs to, so the audit can tell an agent from the policy. */
135
+ private readonly auditScope = new AsyncLocalStorage<AuditScope>();
136
+ /** Two audit rows can share a millisecond; the tick keeps their stored order equal to their arrival. */
137
+ private auditTick = 0;
138
+ private readonly log: AcquisitionLog;
139
+ /** The host side of one approval, built once with the node in `start`. */
140
+ private peers!: ApprovalDeps;
76
141
  private server?: ReturnType<typeof Bun.serve>;
77
142
  private keyDir?: string;
78
143
  private network?: NetworkDiscovery;
@@ -88,12 +153,60 @@ class OmpRuntime implements PluginRuntime {
88
153
  this.mainAgentId = main.agent.id;
89
154
  this.storage = new SqliteStorage(join(dirname(config.path), "omp.db"));
90
155
  this.contexts = new ContextRegistry(this.storage, config.node.id, main.agent.id);
156
+ this.log = new AcquisitionLog(this.storage);
91
157
  }
92
158
  async start(): Promise<void> {
93
159
  let handler: (request: Request) => Promise<Response> = async () => new Response(t("initializing"), { status: 503 });
94
- this.server = Bun.serve({ hostname: this.config.server.bind, port: 0, fetch: request => handler(request) });
160
+ // RFC 0018 §2.1: the address a peer statement arrived from is known only here. The
161
+ // library calls `notify` synchronously from inside the request it is serving, so the
162
+ // connection's own store is still in scope when the trust event is handled.
163
+ const serve = (port: number) => Bun.serve({
164
+ hostname: this.config.server.bind, port,
165
+ fetch: (request, server) => this.connection.run(server.requestIP(request)?.address, () => handler(request)),
166
+ });
167
+ // A configured public_url is a promise about where this node answers. Advertising it
168
+ // without listening there leaves every peer that pinned the address calling a port
169
+ // nobody serves, so the port is taken unless it is already in use. Without a public_url
170
+ // the port this node last bound is reused: an address that changes on every start is one
171
+ // every peer has to be taught again, and the operator's only recourse is to pin it by hand.
172
+ const wanted = publicPort(this.config.server.public_url) ?? this.config.server.port;
173
+ let fellBack = false;
174
+ // `server.port` is optional because Bun omits it for a unix socket; this listener is TCP.
175
+ let bound: number;
176
+ try {
177
+ this.server = serve(wanted ?? 0);
178
+ bound = this.server.port!;
179
+ } catch (error) {
180
+ if (wanted === undefined) throw error;
181
+ fellBack = true;
182
+ this.server = serve(0);
183
+ bound = this.server.port!;
184
+ this.pi.logger.warn("onlyne.port.taken", { wanted, bound, error: errorText(error) });
185
+ }
95
186
  const host = advertisedHost(this.config.server.bind);
96
- this.endpoint = `http://${host.includes(":") ? `[${host}]` : host}:${this.server.port}/onlyne/v3`;
187
+ const actual = `http://${host.includes(":") ? `[${host}]` : host}:${bound}/onlyne/v3`;
188
+ this.endpoint = this.config.server.public_url === undefined || fellBack ? actual : normalizeServiceUrl(this.config.server.public_url);
189
+ // The port the system chose is this node's address for as long as it keeps it. Writing it
190
+ // back is what makes the next start answer where the peers already look, and it costs one
191
+ // file write on the first start and none afterwards.
192
+ if (this.config.server.public_url === undefined && this.config.server.port !== bound) {
193
+ await this.rememberPort(bound);
194
+ }
195
+ // RFC 0018 §2.3/§5.1: a trust seed is a human pre-authorization the policy never judges,
196
+ // so the bypass is written where it can be re-read: one line per seed naming the config
197
+ // file it came from, and the policy status beside it. A fingerprint is the audit fact,
198
+ // never a credential. The discovery axis is separate and, like the policy, reported here.
199
+ const policy = acquisitionOf(this.config);
200
+ this.pi.logger.info("onlyne.acquisition.policy", {
201
+ policy: policy.policy, allow: [...policy.allow],
202
+ allow_loopback: ALLOW_LOOPBACK,
203
+ });
204
+ for (const seed of this.config.trust ?? []) {
205
+ this.pi.logger.info("onlyne.acquisition.seed", {
206
+ node: seed.node_id, fingerprint: seed.fingerprint, source: this.config.path,
207
+ actor: "human", conclusion: "seed_authorized",
208
+ });
209
+ }
97
210
  const instanceId = crypto.randomUUID();
98
211
  // The decider probes peers for their execution contexts, so it must use the node's own
99
212
  // transport: an unsigned probe is refused by every keyed peer.
@@ -107,6 +220,9 @@ class OmpRuntime implements PluginRuntime {
107
220
  else this.pi.logger.debug(message, fields);
108
221
  },
109
222
  listPeers: async () => (await this.node.discovery.listRecords()).filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now()),
223
+ listTrust: async () => (await this.node.trust.list(this.node.auth)).map(row => ({
224
+ node_id: row.node_id, phase: trustPhase(row), base_url: row.base_url,
225
+ })),
110
226
  listReceiverContexts: async id => id === this.config.node.id
111
227
  ? [
112
228
  { alias: "main", summary: t("main"), state: this.main.isIdle() ? "idle" : "running" },
@@ -117,8 +233,24 @@ class OmpRuntime implements PluginRuntime {
117
233
  .map(record => ({ alias: record.alias, summary: record.summary, state: record.state }))),
118
234
  ]
119
235
  : transport!.request(id, "GET", "/contexts", undefined, this.controller.signal),
120
- listThreads: () => allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })),
121
- listMessages: (thread, cursor) => this.node.messages.list(this.node.auth, thread, { attention: "thread", page_size: 100, cursor }),
236
+ // RFC 0018 §3.2: the handshake thread is this host's own record, never a candidate
237
+ // for a conversation the agent routes or reads by name.
238
+ listThreads: async () => (await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })))
239
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
240
+ listJoinedThreads: async () => (await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor })))
241
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
242
+ // A joined thread's entries live on its owner; this node holds no local copy, so the
243
+ // owner's message route is the only source. Both legs keep the same page shape, and
244
+ // resolving a joined `{thread, seq}` reference now reads the entries it names.
245
+ listMessages: async (thread, cursor) => {
246
+ const query = { attention: "thread" as const, page_size: 100, ...(cursor === undefined ? {} : { cursor }) };
247
+ return thread.owner === this.config.node.id
248
+ ? this.node.messages.list(this.node.auth, thread, query)
249
+ : this.node.client.threadEntries(thread, query);
250
+ },
251
+ listInbox: async () => (await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor })))
252
+ .map(row => ({ context: row.context, attention: row.attention })),
253
+ threadMembers: async thread => (await this.node.threads.describe(this.node.auth, thread)).members.map(member => member.peer),
122
254
  });
123
255
  const bridge = createRuntimeBridge({ pi: this.pi, main: this.main, decider: this.decider, contexts: this.contexts, nodeId: this.config.node.id });
124
256
  const projectRoot = dirname(dirname(this.config.path));
@@ -145,14 +277,28 @@ class OmpRuntime implements PluginRuntime {
145
277
  },
146
278
  });
147
279
  transport = this.node.transport;
280
+ this.peers = {
281
+ policy: acquisitionOf(this.config),
282
+ log: this.log,
283
+ observed: async (nodeId, fingerprint) => (await this.log.observation(nodeId, fingerprint))?.observed,
284
+ declared: async nodeId => (await this.node.trust.entry(nodeId))?.base_url,
285
+ recorded: nodeId => this.recorded(nodeId),
286
+ audit: judgement => {
287
+ this.pi.logger.info("onlyne.acquire.decided", judgementFacts(judgement));
288
+ // The caller decided the actor; a judgement made with no scope is the policy's own.
289
+ this.auditScope.getStore()?.capture?.(judgement);
290
+ },
291
+ accept: (nodeId, fingerprint) => this.node.trust.accept(this.node.auth, nodeId, fingerprint),
292
+ revoke: nodeId => this.node.trust.revoke(this.node.auth, nodeId),
293
+ now: () => new Date().toISOString(),
294
+ };
148
295
  const memberships = (await this.node.domains.list(this.node.auth)).items;
149
296
  const current = memberships[0];
150
- const mutation = () => ({ idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() });
151
297
  if (current && current.domain_id !== this.config.region) await this.node.domains.leave(this.node.auth, current.domain_id, { ...mutation(), expected_revision: current.revision });
152
298
  if (!current || current.domain_id !== this.config.region) {
153
299
  let expected_revision: string | undefined;
154
300
  try { expected_revision = (await this.node.domains.describe(this.node.auth, this.config.region)).revision; }
155
- catch (error) { if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error; }
301
+ catch (error) { if (!isNotFoundError(error)) throw error; }
156
302
  await this.node.domains.join(this.node.auth, this.config.region, { ...mutation(), expected_revision });
157
303
  }
158
304
  handler = request => this.node.fetch(request);
@@ -191,11 +337,169 @@ class OmpRuntime implements PluginRuntime {
191
337
  this.pi.logger.info(`onlyne.trust.${event.cause}`, {
192
338
  node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint,
193
339
  });
340
+ // RFC 0018 §2.1: the address this statement arrived from exists only inside the
341
+ // call the library is making right now, and nothing after it can recover it.
342
+ this.trustEvent(event, this.connection.getStore());
194
343
  }
195
344
  this.changed();
196
345
  }
197
346
  onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
198
347
  private changed(): void { for (const listener of this.listeners) listener(); }
348
+ /**
349
+ * RFC 0018 §2/§3: one trust transition's host work — the observation its statement's
350
+ * connection left, the judgement of an invitation this host may approve itself, and the
351
+ * handshake entry the agent reads. Everything is reported by the library's own call, so it
352
+ * runs beside it; a failure here is logged and is never a reason to doubt the committed row.
353
+ */
354
+ private trustEvent(event: TrustNotify, observed: string | undefined): void {
355
+ void this.handleTrust(event, observed).catch(error => {
356
+ this.pi.logger.warn("onlyne.trust.handling_failed", { node: event.entry.node_id, phase: event.phase, error: errorText(error) });
357
+ });
358
+ }
359
+ private async handleTrust(event: TrustNotify, observed: string | undefined): Promise<void> {
360
+ const node = event.entry.node_id;
361
+ const fingerprint = event.entry.fingerprint;
362
+ // The address is recorded for every peer statement, before its verdict. Local
363
+ // transitions never replace it with the address of the approval call (which has none),
364
+ // so an approval decided later still reads the statement's original connection.
365
+ const at = new Date().toISOString();
366
+ if (event.cause === "peer-statement") {
367
+ await this.log.observe(node, fingerprint, {
368
+ ...(observed === undefined ? {} : { observed }), at, cause: event.cause,
369
+ });
370
+ } else await this.log.transition(node, fingerprint, at, event.cause);
371
+ // RFC 0018 §2.3/§5: a pinned seed is a human pre-authorization the policy never judges.
372
+ // The startup log lists the seeds; this row records the statement that actually used one.
373
+ const seed = (this.config.trust ?? []).find(row => row.node_id === node && row.fingerprint === fingerprint);
374
+ if (event.cause === "peer-statement" && seed !== undefined) {
375
+ await this.recordAudit({
376
+ at, actor: "seed", action: "seed_authorized", node_id: node, fingerprint,
377
+ ...(observed === undefined ? {} : { observed }),
378
+ ...(event.entry.base_url === "" ? {} : { declared: event.entry.base_url }),
379
+ policy: this.peers.policy.policy, allowed: true, reason: "seed_authorized", override: true, source: this.config.path,
380
+ });
381
+ }
382
+ // RFC 0018 §2.2: the verdict reads the catalog as it stood when the statement arrived.
383
+ // The record this host derives from that statement is not part of the judgement, or the
384
+ // two conditions would collapse into one.
385
+ const recorded = await this.recorded(node);
386
+ // The statement's own address is also a discovery candidate here, as the tern host's
387
+ // bridge records it.
388
+ if (event.entry.base_url) {
389
+ await this.node.discovery.observe(event.entry.base_url, "trust-statement", node).catch(() => undefined);
390
+ }
391
+ // §2.2: only an invitation whose statement covered a connection this host observed, and
392
+ // whose declared and recorded addresses all stay inside the allow set, is committed
393
+ // without a human deciding first.
394
+ const judgement = event.phase === "invited" && event.cause === "peer-statement"
395
+ ? await judgeApproval(this.peers, { node_id: node, fingerprint, observed, recorded })
396
+ : undefined;
397
+ // §4.1: a verdict is audited whether it allows the approval or refuses it.
398
+ if (judgement !== undefined) await this.recordAudit(this.auditEntry(judgement, "policy", "approve"));
399
+ if (judgement?.verdict.allowed === true) this.approveDeferred(node, fingerprint);
400
+ await this.deliverHandshake(event, observed, judgement);
401
+ }
402
+ /**
403
+ * RFC 0018 §2: the approval an invitation earned is committed outside the library's call,
404
+ * so the admission that notified this host is never re-entered.
405
+ */
406
+ private approveDeferred(node: string, fingerprint: string): void {
407
+ queueMicrotask(() => {
408
+ void this.peers.accept(node, fingerprint)
409
+ .catch(error => this.pi.logger.warn("onlyne.acquire.approve_failed", { node, error: errorText(error) }));
410
+ });
411
+ }
412
+ /**
413
+ * RFC 0018 §3: one handshake entry per `(node, fingerprint, phase)`, written into this
414
+ * node's own thread under a plan bound to the main session. The thread has this node as its
415
+ * only member, so the entry never leaves the node.
416
+ */
417
+ private async deliverHandshake(event: TrustNotify, observed: string | undefined, judgement: ApprovalJudgement | undefined): Promise<void> {
418
+ const node = event.entry.node_id;
419
+ const fingerprint = event.entry.fingerprint;
420
+ if (await this.log.delivered(node, fingerprint, event.phase)) return;
421
+ const handshake = handshakeEntry({
422
+ node_id: node, fingerprint, phase: event.phase, declared: event.entry.base_url, observed,
423
+ policy: this.peers.policy, judgement, approved: judgement?.verdict.allowed === true,
424
+ });
425
+ await this.ensureTrustThread();
426
+ const outcome = await this.node.client.send(this.config.node.id, {
427
+ id: crypto.randomUUID(),
428
+ sender: this.config.node.id,
429
+ delivery_kind: "trust",
430
+ msg: handshake.msg,
431
+ // The mention is what puts the entry in this node's own inbox: the delivery plan
432
+ // names the main context, and the bridge reads the facts for its todo hook.
433
+ meta: { thread: trustThread(this.config.node.id), mentions: [this.config.node.id], data: { trust: handshake.trust } },
434
+ }, { response: "none", execution: "existing", context: MAIN_CONTEXT });
435
+ if (outcome.admission !== "accepted") {
436
+ this.pi.logger.warn("onlyne.trust.delivery_failed", { node, phase: event.phase, error: outcome.error?.message });
437
+ return;
438
+ }
439
+ await this.log.markDelivered(node, fingerprint, event.phase, new Date().toISOString());
440
+ }
441
+ /** RFC 0018 §3.2: the handshake thread, created on first use. Its id is fixed, so it is one thread for the node's lifetime. */
442
+ private async ensureTrustThread(): Promise<void> {
443
+ const ref = trustThread(this.config.node.id);
444
+ try {
445
+ await this.node.threads.describe(this.node.auth, ref);
446
+ return;
447
+ } catch (error) {
448
+ if (!isNotFoundError(error)) throw error;
449
+ }
450
+ await this.node.threads.create(this.node.auth, { title: TRUST_THREAD, id: ref.id, ...mutation() });
451
+ }
452
+ /** RFC 0018 §2.2: every address discovery holds for this node is an outbound candidate the policy must allow. */
453
+ private async recorded(node: string): Promise<string[]> {
454
+ const records = await this.node.discovery.listRecords();
455
+ return [...new Set(records.filter(record => record.node_id === node).map(record => record.base_url))];
456
+ }
457
+ /**
458
+ * The persisted acquisition audit. One row per fact, keyed by its own time so the scan is the
459
+ * history; the oldest rows are pruned past `AUDIT_KEEP`, and the panel reads the newest
460
+ * `AUDIT_VISIBLE` back out. A failed write is logged and never doubts a committed decision.
461
+ */
462
+ private async recordAudit(entry: AcquisitionAuditEntry): Promise<void> {
463
+ if (this.closed) return;
464
+ try {
465
+ await this.storage.transaction(async tx => {
466
+ const rows = await tx.scan<AcquisitionAuditEntry>(AUDIT_PREFIX);
467
+ await tx.put(`${AUDIT_PREFIX}${entry.at}/${String(this.auditTick++).padStart(12, "0")}-${crypto.randomUUID()}`, entry);
468
+ const excess = rows.length + 1 - AUDIT_KEEP;
469
+ for (const row of rows.slice(0, Math.max(0, excess))) await tx.delete(row.key);
470
+ });
471
+ } catch (error) {
472
+ this.pi.logger.warn("onlyne.acquisition.audit_failed", { error: errorText(error) });
473
+ }
474
+ }
475
+ /** One judgement as the audit stores it: the time, the actor, and every input the verdict read. */
476
+ private auditEntry(judgement: ApprovalJudgement, actor: AcquisitionAuditEntry["actor"], action: string): AcquisitionAuditEntry {
477
+ return {
478
+ at: judgement.at, actor, action, node_id: judgement.node_id,
479
+ ...(judgement.fingerprint === undefined ? {} : { fingerprint: judgement.fingerprint }),
480
+ ...(judgement.input.observed === undefined ? {} : { observed: judgement.input.observed }),
481
+ ...(judgement.input.declared === undefined ? {} : { declared: judgement.input.declared }),
482
+ policy: judgement.policy.policy, allowed: judgement.verdict.allowed, reason: judgement.verdict.reason,
483
+ };
484
+ }
485
+ /**
486
+ * What the policy would say about this peer right now, read from the same stored facts a
487
+ * judgement reads. The human path asks only to mark an override; it never gates the action.
488
+ */
489
+ private async policyVerdict(node: string, fingerprint?: string): Promise<{ observed?: string; declared?: string; verdict: AcquireVerdict }> {
490
+ const observed = fingerprint === undefined ? undefined : (await this.log.observation(node, fingerprint))?.observed;
491
+ const declared = (await this.node.trust.entry(node))?.base_url;
492
+ const input: AcquireInput = {
493
+ ...(observed === undefined || observed === "" ? {} : { observed }),
494
+ ...(declared === undefined || declared === "" ? {} : { declared }),
495
+ recorded: await this.recorded(node),
496
+ };
497
+ return {
498
+ ...(observed === undefined ? {} : { observed }),
499
+ ...(declared === undefined ? {} : { declared }),
500
+ verdict: await evaluateAcquisition(this.peers.policy, input),
501
+ };
502
+ }
199
503
  /**
200
504
  * A user-typed address. It is normalized once, read for its own identity and everything it
201
505
  * declares, and only a successful read becomes a seed. The node keeps running: a seed is a
@@ -242,18 +546,90 @@ class OmpRuntime implements PluginRuntime {
242
546
  const saved = await saveConfig({ ...this.config, discovery: { seeds: [...seeds, seed] } });
243
547
  this.config.discovery = saved.discovery;
244
548
  }
549
+ /**
550
+ * The listening port is configuration for the same reason a seed is: a peer holds the
551
+ * address it last reached, and a node that answers somewhere new has moved without telling
552
+ * anyone. One write on the first start, and one more only after a port conflict moved it.
553
+ */
554
+ private async rememberPort(port: number): Promise<void> {
555
+ const saved = await saveConfig({ ...this.config, server: { ...this.config.server, port } });
556
+ this.config.server = saved.server;
557
+ this.pi.logger.info("onlyne.port.persisted", { port, path: this.config.path });
558
+ }
245
559
  /** Stage one of one exchange: the peer's proved fingerprint, and the session that commits it. */
246
560
  async trustBegin(node: string): Promise<KeyExchange> {
247
- return this.node.trust.begin(this.node.auth, node);
561
+ const session = await this.node.trust.begin(this.node.auth, node);
562
+ await this.recordAudit({
563
+ at: new Date().toISOString(), actor: "human", action: "begin",
564
+ node_id: node, fingerprint: session.fingerprint, declared: session.base_url,
565
+ });
566
+ return session;
248
567
  }
249
568
  async trustAccept(node: string, fingerprint: string): Promise<void> {
569
+ // The panel keeps the human gate outside the policy (RFC 0018 §2). Read the verdict first
570
+ // so a row the policy would have refused is recorded as the override it is.
571
+ const admission = await this.policyVerdict(node, fingerprint);
250
572
  await this.node.trust.accept(this.node.auth, node, fingerprint);
573
+ await this.recordAudit({
574
+ at: new Date().toISOString(), actor: "human", action: "accept", node_id: node, fingerprint,
575
+ ...(admission.observed === undefined ? {} : { observed: admission.observed }),
576
+ ...(admission.declared === undefined ? {} : { declared: admission.declared }),
577
+ policy: this.peers.policy.policy, allowed: admission.verdict.allowed, reason: admission.verdict.reason,
578
+ ...(admission.verdict.allowed ? {} : { override: true }),
579
+ });
251
580
  }
252
581
  async trustRevoke(node: string): Promise<void> {
582
+ const row = await this.node.trust.entry(node);
583
+ const observed = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
253
584
  await this.node.trust.revoke(this.node.auth, node);
585
+ await this.recordAudit({
586
+ at: new Date().toISOString(), actor: "human", action: "revoke", node_id: node,
587
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint, declared: row.base_url }),
588
+ ...(observed === undefined ? {} : { observed }),
589
+ });
590
+ }
591
+ /** RFC 0018 §2/§3.1: the agent's approval, refused with `policy_denied` when the policy does not allow it. */
592
+ async acquireAccept(node: string, fingerprint: string): Promise<TrustEntry> {
593
+ let judged: ApprovalJudgement | undefined;
594
+ try {
595
+ return await this.auditScope.run({ actor: "agent", action: "accept", capture: judgement => { judged = judgement; } },
596
+ () => approvePeer(this.peers, node, fingerprint));
597
+ } finally {
598
+ if (judged !== undefined) await this.recordAudit(this.auditEntry(judged, "agent", "accept"));
599
+ }
600
+ }
601
+ /** RFC 0018 §2/§3.1: the agent's revocation, which no policy judges. */
602
+ async acquireRevoke(node: string): Promise<void> {
603
+ await revokePeer(this.peers, node);
604
+ await this.recordAudit({ at: new Date().toISOString(), actor: "agent", action: "revoke", node_id: node });
605
+ }
606
+ /**
607
+ * RFC 0018 §2/§3.1: the agent's initiation of one exchange. The judgement reads only what this
608
+ * host already stores; the peer's key is read after it, so a refusal sends nothing.
609
+ */
610
+ async acquireBegin(node: string): Promise<TrustEntry> {
611
+ let judged: ApprovalJudgement | undefined;
612
+ try {
613
+ return await this.auditScope.run({ actor: "agent", action: "begin", capture: judgement => { judged = judgement; } }, async () => {
614
+ const row = await this.node.trust.entry(node);
615
+ const statement = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
616
+ const records = (await this.node.discovery.listRecords()).filter(record => record.node_id === node);
617
+ const observed = outboundObserved({ statement, records, declared: row?.base_url });
618
+ const request: ApprovalRequest = {
619
+ node_id: node,
620
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint }),
621
+ ...(observed === undefined ? {} : { observed }),
622
+ };
623
+ const committed = await beginPeer(this.peers, target => this.node.trust.begin(this.node.auth, target), request);
624
+ return committed.entry;
625
+ });
626
+ } finally {
627
+ if (judged !== undefined) await this.recordAudit(this.auditEntry(judged, "agent", "begin"));
628
+ }
254
629
  }
255
630
  async trustRotate(): Promise<{ fingerprint: string }> {
256
631
  const identity = await this.node.trust.rotate(this.node.auth);
632
+ await this.recordAudit({ at: new Date().toISOString(), actor: "human", action: "rotate", fingerprint: identity.fingerprint });
257
633
  return { fingerprint: identity.fingerprint };
258
634
  }
259
635
  async refreshDiscovery(): Promise<void> {
@@ -307,10 +683,13 @@ class OmpRuntime implements PluginRuntime {
307
683
  for (const thread of await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor }))) {
308
684
  const ref = thread.thread_ref;
309
685
  hosted.add(`${ref.owner}/${ref.id}`);
686
+ // RFC 0018 §3.2: the handshake entries stay out of the conversation tree. The
687
+ // thread is still recorded above, so its own inbox rows are not re-rendered here.
688
+ if (isTrustThread(this.config.node.id, ref)) continue;
310
689
  const entries = await allPages(cursor => this.node.messages.list(this.node.auth, ref, { attention: "thread", page_size: 100, cursor }));
311
690
  views.push({ ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: thread.title, state: thread.state,
312
691
  unread: inbox.filter(item => !item.injected && item.attention.thread_ref.owner === ref.owner && item.attention.thread_ref.id === ref.id).length,
313
- last_seq: entries.at(-1)?.seq ?? "0", messages: entries.map(entry => ({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation })),
692
+ last_seq: entries.at(-1)?.seq ?? "0", messages: entries.map(entry => ({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation, ...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }) })),
314
693
  });
315
694
  }
316
695
  // A member's own message never comes back through its inbox, so the message that
@@ -319,16 +698,25 @@ class OmpRuntime implements PluginRuntime {
319
698
  // sees the reply and not the question that started it.
320
699
  const outbound = await this.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
321
700
  const seen = new Map<string, ViewerThread>();
701
+ // A grant can arrive before any message, and a thread this node was invited into is a row
702
+ // the viewer must show. The local joined listing supplies those rows; the owner is never
703
+ // asked here, so the viewer stays a read of this node's own records.
704
+ for (const row of await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor }))) {
705
+ const ref = row.thread_ref;
706
+ const key = `${ref.owner}/${ref.id}`;
707
+ if (hosted.has(key) || seen.has(key)) continue;
708
+ seen.set(key, { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: row.last_seq, messages: [], read_only: true });
709
+ }
322
710
  for (const item of inbox) {
323
711
  const ref = item.attention.thread_ref;
324
712
  const key = `${ref.owner}/${ref.id}`;
325
713
  if (hosted.has(key)) continue;
326
- const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [] };
714
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
327
715
  seen.set(key, view);
328
716
  if (!item.injected) view.unread++;
329
717
  const entry = item.attention.entry;
330
718
  if (entry !== undefined) {
331
- view.messages.push({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation });
719
+ view.messages.push({ ref: { sender: entry.message.sender, id: entry.message.id }, seq: entry.seq, direction: entry.message.sender === this.config.node.id ? "out" : "in", author: entry.authenticated_author, at: entry.at, text: entry.message.msg, relation: entry.message.meta?.relation, ...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }) });
332
720
  view.last_seq = entry.seq;
333
721
  }
334
722
  }
@@ -338,10 +726,10 @@ class OmpRuntime implements PluginRuntime {
338
726
  const ref = receipt.thread_ref;
339
727
  const key = `${ref.owner}/${ref.id}`;
340
728
  if (hosted.has(key)) continue;
341
- const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [] };
729
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
342
730
  seen.set(key, view);
343
731
  const message = row.value.request.message;
344
- view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation });
732
+ view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation, ...(message.meta?.work === undefined ? {} : { work: message.meta.work }) });
345
733
  if (Number(receipt.seq) > Number(view.last_seq)) view.last_seq = receipt.seq;
346
734
  }
347
735
  for (const view of seen.values()) view.messages.sort((left, right) => Number(left.seq) - Number(right.seq));
@@ -377,9 +765,28 @@ class OmpRuntime implements PluginRuntime {
377
765
  last_seen: record.last_seen,
378
766
  }));
379
767
  const trust = this.node.keys.enabled ? await this.node.trust.list(this.node.auth) : [];
768
+ // RFC 0018 §2.1: the declared address is what the peer wrote; the observed one is the
769
+ // connection this host recorded for its last statement. Both are shown, never merged.
770
+ const trustRows: ViewerTrustRow[] = [];
771
+ for (const row of trust) {
772
+ const observation = await this.log.observation(row.node_id, row.fingerprint);
773
+ trustRows.push({
774
+ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url,
775
+ ...(observation?.observed === undefined ? {} : { observed: observation.observed }),
776
+ });
777
+ }
778
+ // Newest first: the panel renders this array in order and never sorts it.
779
+ const auditRows = await this.storage.transaction(tx => tx.scan<AcquisitionAuditEntry>(AUDIT_PREFIX));
780
+ const audit = auditRows
781
+ .sort((left, right) => right.value.at.localeCompare(left.value.at) || right.key.localeCompare(left.key))
782
+ .slice(0, AUDIT_VISIBLE)
783
+ .map(row => row.value);
784
+ const policy = acquisitionOf(this.config);
380
785
  const identity = this.node.keys.enabled ? await this.node.keys.describe() : undefined;
381
786
  return { region: this.config.region, endpoint: this.endpoint, contexts, queue, threads: views, discovery, contacts,
382
- trust: trust.map(row => ({ node_id: row.node_id, fingerprint: row.fingerprint, phase: trustPhase(row), base_url: row.base_url })),
787
+ trust: trustRows,
788
+ acquisition: { policy: policy.policy, allow: [...policy.allow], allow_loopback: ALLOW_LOOPBACK },
789
+ audit,
383
790
  ...(identity === undefined ? {} : { identity: { public_key: identity.public_key, fingerprint: identity.fingerprint } }),
384
791
  ...(this.discoveryError === undefined ? {} : { discovery_error: this.discoveryError }) };
385
792
  }
@@ -407,6 +814,35 @@ export async function allPages<T>(read: (cursor?: string) => Promise<Page<T>>):
407
814
 
408
815
  function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
409
816
 
817
+ /** Check if an error represents an Onlyne not_found condition, independent of instanceof identity. */
818
+ function isNotFoundError(error: unknown): boolean {
819
+ if (error instanceof OnlyneError) return error.error.code === "not_found";
820
+ if (typeof error === "object" && error !== null) {
821
+ const failure = (error as { error?: { code?: string } }).error;
822
+ if (failure?.code === "not_found") return true;
823
+ if ((error as { message?: string }).message === "The requested record is unavailable.") return true;
824
+ }
825
+ return false;
826
+ }
827
+
828
+ /**
829
+ * RFC 0018 §2.1–2.2: the address an outbound judgement reads for a peer this host may initiate
830
+ * with. The arrival address this host recorded for the peer's last statement is the strongest
831
+ * fact it holds; a peer that never sent one is judged at an address discovery verified by
832
+ * probing, and with no record at all at the address the library's own fallback would connect to.
833
+ */
834
+ export function outboundObserved(facts: {
835
+ statement?: string;
836
+ records: readonly DiscoveryRecord[];
837
+ declared?: string;
838
+ }): string | undefined {
839
+ if (facts.statement !== undefined && facts.statement !== "") return facts.statement;
840
+ const verified = facts.records
841
+ .filter(record => record.verified_at !== undefined)
842
+ .sort((left, right) => right.received_at.localeCompare(left.received_at));
843
+ return verified[0]?.base_url ?? (facts.declared === "" ? undefined : facts.declared);
844
+ }
845
+
410
846
  /** A verified record beats an unverified one; within each class the newest observation wins. */
411
847
  function newerRecord(candidate: DiscoveryRecord, kept: DiscoveryRecord): boolean {
412
848
  const candidateVerified = candidate.verified_at !== undefined;