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/README.md +16 -8
- package/package.json +3 -3
- package/src/bridge-text.ts +40 -8
- package/src/config.ts +39 -5
- package/src/decider-text.ts +36 -21
- package/src/decider.ts +253 -548
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +291 -0
- package/src/messages.ts +134 -16
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +454 -18
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +136 -21
- package/src/types.ts +65 -57
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +82 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +116 -4
- package/src/ui/viewer.ts +650 -61
- package/src/urls-text.ts +24 -0
- package/src/urls.ts +206 -33
- package/src/work.ts +10 -0
- package/src/host-text.ts +0 -86
- package/src/node.ts +0 -127
- package/src/store.ts +0 -189
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 "
|
|
13
|
-
import { canAnswer, ContextRegistry, SqliteStorage } from "
|
|
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
|
|
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> {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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;
|