omp-onlyne 0.2.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 +3 -3
- package/package.json +3 -3
- package/src/bridge-text.ts +4 -8
- package/src/config.ts +6 -4
- package/src/decider-text.ts +3 -6
- package/src/decider.ts +124 -670
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +2 -2
- package/src/messages.ts +24 -6
- 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 +201 -25
- package/src/tool.ts +11 -2
- package/src/types.ts +60 -66
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +77 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +112 -4
- package/src/ui/viewer.ts +629 -57
- package/src/urls-text.ts +6 -0
- package/src/urls.ts +36 -4
- package/src/work.ts +10 -0
- package/src/acquire.ts +0 -182
- package/src/host-text.ts +0 -92
- package/src/node.ts +0 -139
- package/src/store.ts +0 -189
package/src/urls-text.ts
CHANGED
|
@@ -132,6 +132,9 @@ export const en = {
|
|
|
132
132
|
suffixReply: " · reply to {ref}",
|
|
133
133
|
suffixEntry: " · entry {url}",
|
|
134
134
|
suffixExpired: " · expired",
|
|
135
|
+
workLine: " → work {unit}{parent}{attempt}",
|
|
136
|
+
workParent: " · parent {unit}",
|
|
137
|
+
workAttempt: " · attempt {attempt}",
|
|
135
138
|
|
|
136
139
|
resourcesTitle: "# Resources · {node}",
|
|
137
140
|
resourceOwnerError: "- unavailable: {code} · {message}",
|
|
@@ -280,6 +283,9 @@ export const zh: Record<keyof typeof en, string> = {
|
|
|
280
283
|
suffixReply: " · 回复 {ref}",
|
|
281
284
|
suffixEntry: " · 条目 {url}",
|
|
282
285
|
suffixExpired: " · 已过期",
|
|
286
|
+
workLine: " → 工作单元 {unit}{parent}{attempt}",
|
|
287
|
+
workParent: " · 父单元 {unit}",
|
|
288
|
+
workAttempt: " · 第 {attempt} 次",
|
|
283
289
|
|
|
284
290
|
resourcesTitle: "# 资源 · {node}",
|
|
285
291
|
resourceOwnerError: "- 不可用:{code} · {message}",
|
package/src/urls.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ThreadDescription,
|
|
20
20
|
type ThreadRef,
|
|
21
21
|
type TrustPhase,
|
|
22
|
+
type WorkRef,
|
|
22
23
|
} from "@onlyne/lib";
|
|
23
24
|
import { createTranslator } from "@onlyne/node-host/i18n";
|
|
24
25
|
import {
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
import { resourceQuery } from "./resources.ts";
|
|
33
34
|
import { getRuntime, type PluginRuntime } from "./runtime.ts";
|
|
34
35
|
import { isTrustThread, trustThread } from "./handshake.ts";
|
|
35
|
-
import type { ContextRecord } from "
|
|
36
|
+
import type { ContextRecord } from "@onlyne/node-host/types";
|
|
36
37
|
import { urlsText } from "./urls-text.ts";
|
|
37
38
|
|
|
38
39
|
type Caller = { main: boolean; agent_id?: string };
|
|
@@ -53,13 +54,15 @@ const TITLE_CHARS = 80;
|
|
|
53
54
|
|
|
54
55
|
const handler: ProtocolHandler = {
|
|
55
56
|
scheme: "onlyne",
|
|
56
|
-
|
|
57
|
+
// Every view reads live node state: threads, inbox, neighbors and conversations change
|
|
58
|
+
// between two reads of the same URL, so OMP must never serve a cached copy.
|
|
59
|
+
spec: { backing: "virtual", selectors: "lines", immutable: false },
|
|
57
60
|
|
|
58
61
|
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
59
62
|
const runtime = getRuntime();
|
|
60
63
|
if (runtime === undefined) throw new Error(t("notConfigured"));
|
|
61
64
|
const rendered = await render(runtime, callerOf(runtime, context), viewOf(url));
|
|
62
|
-
return { url: url.href, content: rendered.content, contentType: rendered.contentType
|
|
65
|
+
return { url: url.href, content: rendered.content, contentType: rendered.contentType };
|
|
63
66
|
},
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -475,11 +478,23 @@ export async function neighborRows(runtime: PluginRuntime): Promise<NeighborRow[
|
|
|
475
478
|
base_url: record.base_url,
|
|
476
479
|
...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
|
|
477
480
|
...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
|
|
478
|
-
verified,
|
|
481
|
+
verified: verified || entry?.state === "trusted",
|
|
479
482
|
phase: entry === undefined ? "no_exchange" : trustPhase(entry),
|
|
480
483
|
fingerprint: entry?.fingerprint ?? null,
|
|
481
484
|
});
|
|
482
485
|
}
|
|
486
|
+
// A trust row outlives a discovery record: a peer paired earlier is still a neighbor
|
|
487
|
+
// before mDNS or a seed refresh brings it back, and the listing says so.
|
|
488
|
+
for (const entry of trust.values()) {
|
|
489
|
+
if (entry.node_id === runtime.config.node.id || byNode.has(entry.node_id)) continue;
|
|
490
|
+
byNode.set(entry.node_id, {
|
|
491
|
+
node_id: entry.node_id,
|
|
492
|
+
base_url: entry.base_url,
|
|
493
|
+
verified: entry.state === "trusted",
|
|
494
|
+
phase: trustPhase(entry),
|
|
495
|
+
fingerprint: entry.fingerprint,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
483
498
|
return [...byNode.values()].sort((left, right) => left.node_id.localeCompare(right.node_id));
|
|
484
499
|
}
|
|
485
500
|
|
|
@@ -587,6 +602,7 @@ function outboundRow(outbound: Outbound): { position: string; lines: string[] }
|
|
|
587
602
|
const at = outbound.created_at ?? null;
|
|
588
603
|
const status = [outbound.state, outbound.error?.code ?? ""].filter(part => part.length > 0).join(" · ");
|
|
589
604
|
const reply = related === undefined ? "" : t("suffixReply", { ref: `${related.sender}/${related.id}` });
|
|
605
|
+
const work = workText(message.meta?.work);
|
|
590
606
|
const position = `${at ?? ""}\u0000${refKey(ref)}`;
|
|
591
607
|
const lines = [
|
|
592
608
|
t("outboundRow", {
|
|
@@ -596,6 +612,7 @@ function outboundRow(outbound: Outbound): { position: string; lines: string[] }
|
|
|
596
612
|
status,
|
|
597
613
|
reply,
|
|
598
614
|
}),
|
|
615
|
+
...(work === undefined ? [] : [work]),
|
|
599
616
|
` ${firstLine(message.msg, SNIPPET_CHARS)}`,
|
|
600
617
|
t("outboundLinks", { message: messagePath(ref), conversation: conversationPath(ref) }),
|
|
601
618
|
];
|
|
@@ -1448,6 +1465,8 @@ function renderEntryRow(entry: Entry, nodeId: string): string {
|
|
|
1448
1465
|
}),
|
|
1449
1466
|
];
|
|
1450
1467
|
if (related !== undefined) lines.push(t("replyTo", { ref: refKey(related), url: messagePath(related) }));
|
|
1468
|
+
const work = workText(entry.message.meta?.work);
|
|
1469
|
+
if (work !== undefined) lines.push(work);
|
|
1451
1470
|
lines.push(t("historyLinks", {
|
|
1452
1471
|
message: messagePath(ref),
|
|
1453
1472
|
conversation: conversationPath(ref),
|
|
@@ -1479,6 +1498,8 @@ function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
|
1479
1498
|
if (related !== undefined) {
|
|
1480
1499
|
lines.push(t("replyTo", { ref: `${related.sender}/${related.id}`, url: messagePath(related) }));
|
|
1481
1500
|
}
|
|
1501
|
+
const work = workText(row.message.meta?.work);
|
|
1502
|
+
if (work !== undefined) lines.push(work);
|
|
1482
1503
|
const entry = thread === undefined || row.seq === undefined || thread.owner !== nodeId
|
|
1483
1504
|
? ""
|
|
1484
1505
|
: t("suffixEntry", { url: entryPath(thread, row.seq) });
|
|
@@ -1486,3 +1507,14 @@ function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
|
1486
1507
|
for (const line of row.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
|
|
1487
1508
|
return lines.join("\n");
|
|
1488
1509
|
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* The work unit a message belongs to (RFC 0003 §1), as the reader needs it: the unit a
|
|
1513
|
+
* receiver dedupes on, the enclosing unit, and which attempt this is.
|
|
1514
|
+
*/
|
|
1515
|
+
function workText(work: WorkRef | undefined): string | undefined {
|
|
1516
|
+
if (work === undefined) return undefined;
|
|
1517
|
+
const parent = work.parent === undefined || work.parent === null ? "" : t("workParent", { unit: work.parent });
|
|
1518
|
+
const attempt = work.attempt === undefined ? "" : t("workAttempt", { attempt: String(work.attempt) });
|
|
1519
|
+
return t("workLine", { unit: work.unit, parent, attempt });
|
|
1520
|
+
}
|
package/src/work.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkRef } from "@onlyne/lib";
|
|
2
|
+
|
|
3
|
+
/** The work unit as one readable value: the dedupe key, its parent and the attempt number. */
|
|
4
|
+
export function workValue(work: WorkRef): string {
|
|
5
|
+
return [
|
|
6
|
+
work.unit,
|
|
7
|
+
work.parent === undefined || work.parent === null ? "" : `parent ${work.parent}`,
|
|
8
|
+
work.attempt === undefined ? "" : `attempt ${work.attempt}`,
|
|
9
|
+
].filter(part => part.length > 0).join(" · ");
|
|
10
|
+
}
|
package/src/acquire.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { lookup } from "node:dns/promises";
|
|
2
|
-
import { BlockList, isIP } from "node:net";
|
|
3
|
-
|
|
4
|
-
/** RFC 0018 §2: how far a peer may be acquired without a human confirming first. */
|
|
5
|
-
export type AcquirePolicy = "all" | "segment" | "none";
|
|
6
|
-
|
|
7
|
-
/** RFC 0018 §5: host-side acquisition settings. The library never reads them. */
|
|
8
|
-
export interface AcquisitionPolicy {
|
|
9
|
-
policy: AcquirePolicy;
|
|
10
|
-
/** CIDR blocks and host names; only read when `policy` is `segment`. */
|
|
11
|
-
allow: string[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The default delegation: loopback only. Discovery excludes loopback by default, so the two
|
|
16
|
-
* defaults together mean a remote peer is neither discovered nor approved (RFC 0018 §5.1).
|
|
17
|
-
*/
|
|
18
|
-
export const DEFAULT_ACQUISITION: AcquisitionPolicy = {
|
|
19
|
-
policy: "segment",
|
|
20
|
-
allow: ["127.0.0.0/8", "::1/128"],
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** Turns a host name into the addresses it names; an empty list means it names none. */
|
|
24
|
-
export type Resolver = (host: string) => Promise<string[]>;
|
|
25
|
-
|
|
26
|
-
const dnsResolver: Resolver = async host => (await lookup(host, { all: true })).map(row => row.address);
|
|
27
|
-
|
|
28
|
-
/** The configured policy, or the documented default when the config carries none. */
|
|
29
|
-
export function acquisitionOf(config: { acquisition?: AcquisitionPolicy }): AcquisitionPolicy {
|
|
30
|
-
return config.acquisition ?? DEFAULT_ACQUISITION;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type AcquireReason =
|
|
34
|
-
| "policy_all"
|
|
35
|
-
| "policy_segment"
|
|
36
|
-
| "policy_denied_none"
|
|
37
|
-
| "policy_unobserved"
|
|
38
|
-
| "policy_denied_observed"
|
|
39
|
-
| "policy_denied_declared"
|
|
40
|
-
| "policy_denied_recorded";
|
|
41
|
-
|
|
42
|
-
/** RFC 0018 §2.1–2.2: what the host observed, what the peer declared, what discovery recorded. */
|
|
43
|
-
export interface AcquireInput {
|
|
44
|
-
/** The address this statement's connection arrived from; absent when it was not observed. */
|
|
45
|
-
observed?: string;
|
|
46
|
-
/** The `base_url` the peer wrote into its own statement. */
|
|
47
|
-
declared?: string;
|
|
48
|
-
/** The discovery addresses recorded for this node id when the statement arrived. */
|
|
49
|
-
recorded?: readonly string[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface AcquireVerdict {
|
|
53
|
-
allowed: boolean;
|
|
54
|
-
reason: AcquireReason;
|
|
55
|
-
/** The allow entry the observed address matched, when one did. */
|
|
56
|
-
matched?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface Block {
|
|
60
|
-
network: string;
|
|
61
|
-
prefix: number;
|
|
62
|
-
type: "ipv4" | "ipv6";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const HOST_NAME = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
|
|
66
|
-
|
|
67
|
-
/** One `[acquisition] allow` entry: `network/prefix`, or a host name to accept as written. */
|
|
68
|
-
export function parseAllowBlock(value: string): Block | { name: string } | undefined {
|
|
69
|
-
const trimmed = value.trim();
|
|
70
|
-
if (trimmed.length === 0) return undefined;
|
|
71
|
-
const slash = trimmed.lastIndexOf("/");
|
|
72
|
-
if (slash <= 0) {
|
|
73
|
-
// A name is matched by its own spelling, so listing it involves no resolution. A bare
|
|
74
|
-
// address is not a block: it needs a prefix to say what it covers.
|
|
75
|
-
if (isIP(trimmed) !== 0 || !HOST_NAME.test(trimmed)) return undefined;
|
|
76
|
-
return { name: trimmed.toLowerCase() };
|
|
77
|
-
}
|
|
78
|
-
const prefix = Number(trimmed.slice(slash + 1));
|
|
79
|
-
if (!Number.isInteger(prefix) || prefix < 0) return undefined;
|
|
80
|
-
const network = literalAddress(trimmed.slice(0, slash));
|
|
81
|
-
if (network === undefined) return undefined;
|
|
82
|
-
if (prefix > (network.type === "ipv4" ? 32 : 128)) return undefined;
|
|
83
|
-
return { network: network.address, prefix, type: network.type };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** The host a value names: a URL's hostname, or the value itself when it is already a host. */
|
|
87
|
-
function hostOf(value: string): string | undefined {
|
|
88
|
-
const trimmed = value.trim();
|
|
89
|
-
if (trimmed.length === 0) return undefined;
|
|
90
|
-
if (!trimmed.includes("://")) return trimmed.replace(/^\[(.*)\]$/, "$1");
|
|
91
|
-
try {
|
|
92
|
-
const host = new URL(trimmed).hostname;
|
|
93
|
-
return host.replace(/^\[(.*)\]$/, "$1");
|
|
94
|
-
} catch {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** A host that is already a literal address, with IPv4-mapped IPv6 read as IPv4. */
|
|
100
|
-
function literalAddress(host: string): { address: string; type: "ipv4" | "ipv6" } | undefined {
|
|
101
|
-
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
102
|
-
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
|
|
103
|
-
if (mapped !== null) return { address: mapped[1]!, type: "ipv4" };
|
|
104
|
-
const family = isIP(bare);
|
|
105
|
-
if (family === 4) return { address: bare, type: "ipv4" };
|
|
106
|
-
if (family === 6) return { address: bare, type: "ipv6" };
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function blockHolds(address: { address: string; type: "ipv4" | "ipv6" }, block: Block): boolean {
|
|
111
|
-
if (block.type !== address.type) return false;
|
|
112
|
-
const blocks = new BlockList();
|
|
113
|
-
blocks.addSubnet(block.network, block.prefix, block.type);
|
|
114
|
-
return blocks.check(address.address, block.type);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** The allow entry an address falls in, or undefined. */
|
|
118
|
-
function entryFor(address: { address: string; type: "ipv4" | "ipv6" }, allow: readonly string[]): string | undefined {
|
|
119
|
-
for (const entry of allow) {
|
|
120
|
-
const block = parseAllowBlock(entry);
|
|
121
|
-
if (block === undefined || "name" in block) continue;
|
|
122
|
-
if (blockHolds(address, block)) return entry.trim();
|
|
123
|
-
}
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* The allow entry a value matches, or undefined. A literal address is judged as itself. A host
|
|
129
|
-
* name matches an allow entry of the same name; otherwise it is resolved and every address it
|
|
130
|
-
* names must fall inside the allow set (RFC 0018 §2.1) — a name that resolves to nothing, or to
|
|
131
|
-
* one address outside the set, matches nothing.
|
|
132
|
-
*/
|
|
133
|
-
export async function allowMatch(value: string, allow: readonly string[], resolve: Resolver = dnsResolver): Promise<string | undefined> {
|
|
134
|
-
const host = hostOf(value);
|
|
135
|
-
if (host === undefined) return undefined;
|
|
136
|
-
const literal = literalAddress(host);
|
|
137
|
-
if (literal !== undefined) return entryFor(literal, allow);
|
|
138
|
-
const named = allow.find(entry => {
|
|
139
|
-
const block = parseAllowBlock(entry);
|
|
140
|
-
return block !== undefined && "name" in block && block.name === host.toLowerCase();
|
|
141
|
-
});
|
|
142
|
-
if (named !== undefined) return named.trim();
|
|
143
|
-
const resolved = await resolve(host).catch(() => [] as string[]);
|
|
144
|
-
let matched: string | undefined;
|
|
145
|
-
for (const address of resolved) {
|
|
146
|
-
const target = literalAddress(address);
|
|
147
|
-
if (target === undefined) return undefined;
|
|
148
|
-
const entry = entryFor(target, allow);
|
|
149
|
-
if (entry === undefined) return undefined;
|
|
150
|
-
matched ??= entry;
|
|
151
|
-
}
|
|
152
|
-
return matched;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* RFC 0018 §2: decide whether this peer may be approved without a human confirming first.
|
|
157
|
-
*
|
|
158
|
-
* The declared address is checked because the trust row stores it and `peerBase` falls back to
|
|
159
|
-
* it when discovery has no record. The recorded addresses are checked because
|
|
160
|
-
* `discovery.resolve()` re-probes every one of them before it connects, so a record outside the
|
|
161
|
-
* allow set is an outbound request the policy never authorized.
|
|
162
|
-
*/
|
|
163
|
-
export async function evaluateAcquisition(
|
|
164
|
-
policy: AcquisitionPolicy,
|
|
165
|
-
input: AcquireInput,
|
|
166
|
-
resolve: Resolver = dnsResolver,
|
|
167
|
-
): Promise<AcquireVerdict> {
|
|
168
|
-
if (policy.policy === "none") return { allowed: false, reason: "policy_denied_none" };
|
|
169
|
-
if (policy.policy === "all") return { allowed: true, reason: "policy_all" };
|
|
170
|
-
if (input.observed === undefined) return { allowed: false, reason: "policy_unobserved" };
|
|
171
|
-
const observed = await allowMatch(input.observed, policy.allow, resolve);
|
|
172
|
-
if (observed === undefined) return { allowed: false, reason: "policy_denied_observed" };
|
|
173
|
-
if (input.declared !== undefined && await allowMatch(input.declared, policy.allow, resolve) === undefined) {
|
|
174
|
-
return { allowed: false, reason: "policy_denied_declared", matched: observed };
|
|
175
|
-
}
|
|
176
|
-
for (const address of input.recorded ?? []) {
|
|
177
|
-
if (await allowMatch(address, policy.allow, resolve) === undefined) {
|
|
178
|
-
return { allowed: false, reason: "policy_denied_recorded", matched: observed };
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return { allowed: true, reason: "policy_segment", matched: observed };
|
|
182
|
-
}
|
package/src/host-text.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { createTranslator } from "@onlyne/node-host/i18n";
|
|
2
|
-
const en = {
|
|
3
|
-
regionEmpty: "region must not be empty.",
|
|
4
|
-
nodeIdEmpty: "node.id must not be empty.",
|
|
5
|
-
addressEmpty: "A node address must not be empty.",
|
|
6
|
-
addressUrl: "A node address must be an absolute URL, got {value}.",
|
|
7
|
-
addressScheme: "A node address must use http or https, got {value}.",
|
|
8
|
-
addressShape: "A node address is an origin (http://host:7801) or a service URL (http://host:7801{service}), got {value}.",
|
|
9
|
-
configMissing: "No Onlyne configuration at {path}. Run /onlyne init to create it.",
|
|
10
|
-
configParse: "{path}: {error}",
|
|
11
|
-
languageInvalid: "language must be en or zh-CN.",
|
|
12
|
-
keyScopeInvalid: "node.key_scope must be project, global or none, got {value}.",
|
|
13
|
-
publicUrlEmpty: "server.public_url must not be empty.",
|
|
14
|
-
bindEmpty: "server.bind must not be empty.",
|
|
15
|
-
bindShape: "server.bind must be a hostname or IP address, got {value}.",
|
|
16
|
-
pageSizeRange: "threads.page_size must be an integer from 1 to 100, got {value}.",
|
|
17
|
-
retiredEndpoint: "node.endpoint is not supported; the endpoint comes from server.bind and the listening port.",
|
|
18
|
-
portRange: "server.port must be an integer from 0 to 65535, got {value}.",
|
|
19
|
-
retiredPeers: "peers is not supported; peers are discovered over the network and kept in the project SQLite.",
|
|
20
|
-
retiredOmp: "omp is not supported; delivery and spawning are decided by the OMP judge.",
|
|
21
|
-
retiredAttention: "threads.default_attention is not supported; reads default to the attention list.",
|
|
22
|
-
tableExpected: "{path}: {key} must be a table.",
|
|
23
|
-
stringExpected: "{field} must be a string.",
|
|
24
|
-
integerExpected: "{field} must be an integer.",
|
|
25
|
-
stringArrayExpected: "{field} must be an array of non-empty strings.",
|
|
26
|
-
trustArray: "{path}: trust must be an array of tables.",
|
|
27
|
-
trustEntry: "{path}: a trust entry needs node_id and a 16-character fingerprint.",
|
|
28
|
-
acquirePolicy: "acquisition.policy must be all, segment or none, got {value}.",
|
|
29
|
-
acquireAllow: "acquisition.allow entries must be network/prefix blocks, got {value}.",
|
|
30
|
-
acquireDenied: "The acquisition policy {policy} refused this approval: {reason}.",
|
|
31
|
-
addressUnavailable: "No concrete network address is available for this bind host.",
|
|
32
|
-
keyProvider: "External credential references need a host key provider.",
|
|
33
|
-
loopbackBind: "The unauthenticated development binding requires a loopback bind host. Create a node key to serve a remote address.",
|
|
34
|
-
managementIdentity: "HTTP callers cannot claim this node's management identity.",
|
|
35
|
-
mentionNotFound: "No unique discovered node is named {name}.",
|
|
36
|
-
guideContacts: "Contacts",
|
|
37
|
-
guidePending: "Pending",
|
|
38
|
-
guideAccept: "Accept",
|
|
39
|
-
guideDiscover: "Discover",
|
|
40
|
-
guideRequestExchange: "Request key exchange",
|
|
41
|
-
resourcePageSize: "page_size must be from 1 to 100.",
|
|
42
|
-
resourceOwner: "Resource owner must be a non-empty node id.",
|
|
43
|
-
resourceRef: "Resource reads need meta.resource {owner,id}.",
|
|
44
|
-
resourceSince: "since must be a decimal event id.",
|
|
45
|
-
resourceOp: "Resource reads use list, describe, log, event with event_id, operations, or status with operation_id.",
|
|
46
|
-
};
|
|
47
|
-
const zh: Record<keyof typeof en, string> = {
|
|
48
|
-
regionEmpty: "region 不能为空。",
|
|
49
|
-
nodeIdEmpty: "node.id 不能为空。",
|
|
50
|
-
addressEmpty: "节点地址不能为空。",
|
|
51
|
-
addressUrl: "节点地址必须是绝对 URL,收到的是 {value}。",
|
|
52
|
-
addressScheme: "节点地址必须使用 http 或 https,收到的是 {value}。",
|
|
53
|
-
addressShape: "节点地址应写成源站(http://host:7801)或服务 URL(http://host:7801{service}),收到的是 {value}。",
|
|
54
|
-
configMissing: "在 {path} 没有 Onlyne 配置。运行 /onlyne init 创建。",
|
|
55
|
-
configParse: "{path}: {error}",
|
|
56
|
-
languageInvalid: "language 必须是 en 或 zh-CN。",
|
|
57
|
-
keyScopeInvalid: "node.key_scope 必须是 project、global 或 none,收到的是 {value}。",
|
|
58
|
-
publicUrlEmpty: "server.public_url 不能为空。",
|
|
59
|
-
bindEmpty: "server.bind 不能为空。",
|
|
60
|
-
bindShape: "server.bind 必须是主机名或 IP 地址,收到的是 {value}。",
|
|
61
|
-
pageSizeRange: "threads.page_size 必须是 1 到 100 之间的整数,收到的是 {value}。",
|
|
62
|
-
retiredEndpoint: "不支持 node.endpoint;端点由 server.bind 和监听端口决定。",
|
|
63
|
-
portRange: "server.port 必须是 0 到 65535 之间的整数,收到的是 {value}。",
|
|
64
|
-
retiredPeers: "不支持 peers;对端通过网络发现,并保存在项目 SQLite 中。",
|
|
65
|
-
retiredOmp: "不支持 omp;投送与派生由 OMP judge 决定。",
|
|
66
|
-
retiredAttention: "不支持 threads.default_attention;读取默认使用关注列表。",
|
|
67
|
-
tableExpected: "{path}: {key} 必须是表。",
|
|
68
|
-
stringExpected: "{field} 必须是字符串。",
|
|
69
|
-
integerExpected: "{field} 必须是整数。",
|
|
70
|
-
stringArrayExpected: "{field} 必须是非空字符串数组。",
|
|
71
|
-
trustArray: "{path}: trust 必须是表数组。",
|
|
72
|
-
trustEntry: "{path}: 每条 trust 记录需要 node_id 和 16 个字符的指纹。",
|
|
73
|
-
acquirePolicy: "acquisition.policy 必须是 all、segment 或 none,收到的是 {value}。",
|
|
74
|
-
acquireAllow: "acquisition.allow 的条目必须是 network/prefix 形式,收到的是 {value}。",
|
|
75
|
-
acquireDenied: "获取策略 {policy} 拒绝了这次批准:{reason}。",
|
|
76
|
-
addressUnavailable: "这个绑定主机没有可用的具体网络地址。",
|
|
77
|
-
keyProvider: "外部凭据引用需要主机密钥提供者。",
|
|
78
|
-
loopbackBind: "未认证的开发绑定需要回环绑定主机。要对外提供服务,请创建节点密钥。",
|
|
79
|
-
managementIdentity: "HTTP 调用方不能冒用本节点的管理身份。",
|
|
80
|
-
mentionNotFound: "没有唯一发现到的节点名为 {name}。",
|
|
81
|
-
guideContacts: "联系人",
|
|
82
|
-
guidePending: "待确认",
|
|
83
|
-
guideAccept: "同意",
|
|
84
|
-
guideDiscover: "发现",
|
|
85
|
-
guideRequestExchange: "请求交换密钥",
|
|
86
|
-
resourcePageSize: "page_size 必须是 1 到 100。",
|
|
87
|
-
resourceOwner: "资源 owner 必须是非空节点 id。",
|
|
88
|
-
resourceRef: "读取资源需要 meta.resource {owner,id}。",
|
|
89
|
-
resourceSince: "since 必须是十进制事件 id。",
|
|
90
|
-
resourceOp: "读取资源可用 list、describe、log、带 event_id 的 event、operations,或带 operation_id 的 status。",
|
|
91
|
-
};
|
|
92
|
-
export const hostText = createTranslator({ en, "zh-CN": zh });
|
package/src/node.ts
DELETED
|
@@ -1,139 +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
|
-
let target: Target;
|
|
49
|
-
if (id === config.node.id) {
|
|
50
|
-
target = { node_id: id, base_url: deps.endpoint };
|
|
51
|
-
} else {
|
|
52
|
-
try {
|
|
53
|
-
target = await node.discovery.resolve(id);
|
|
54
|
-
} catch (error) {
|
|
55
|
-
const trustEntry = await node.trust.entry(id).catch(() => undefined);
|
|
56
|
-
if (trustEntry?.base_url) {
|
|
57
|
-
target = { node_id: id, base_url: trustEntry.base_url };
|
|
58
|
-
} else {
|
|
59
|
-
throw error;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return { ...target, headers: { "Onlyne-Actor": config.node.id } };
|
|
64
|
-
};
|
|
65
|
-
/**
|
|
66
|
-
* Admission reads the trust projection, never the discovery catalog (design §1.1): a discovered
|
|
67
|
-
* peer is a clue (RFC 0017 §4), and `discovery.known` is process-local, so a restart would refuse
|
|
68
|
-
* a peer whose exchange already completed.
|
|
69
|
-
*/
|
|
70
|
-
const admitted = async (id: string, context: AdmissionContext): Promise<boolean> => {
|
|
71
|
-
if (id === config.node.id) return true;
|
|
72
|
-
return await context.trust(id) === "exchanged";
|
|
73
|
-
};
|
|
74
|
-
const keyDir = deps.keyDir;
|
|
75
|
-
const keys: KeyHook | undefined = deps.keys ?? (keyDir === undefined ? undefined : Object.assign(
|
|
76
|
-
() => loadOrCreateKey(keyDir, config.node.id),
|
|
77
|
-
{ replace: (material: KeyMaterial) => writeKeyMaterial(keyDir, material, { replace: true }) },
|
|
78
|
-
));
|
|
79
|
-
// The library's own signing transport, built here for one reason: a missing trust row has to
|
|
80
|
-
// end at this host's menu. Signing still happens only for a keyed node, and the trust reads
|
|
81
|
-
// resolve lazily because the service exists only once the node is built.
|
|
82
|
-
const signing: SigningTransportOptions | undefined = keys === undefined ? undefined : {
|
|
83
|
-
trust: {
|
|
84
|
-
entry: id => node.trust.entry(id),
|
|
85
|
-
signRequest: (request, target, raw) => node.trust.signRequest(request, target, raw),
|
|
86
|
-
verifyResponse: (target, request, response, body) => node.trust.verifyResponse(target, request, response, body),
|
|
87
|
-
},
|
|
88
|
-
guidance: details => ({
|
|
89
|
-
entry: details.required_action === "confirm_exchange"
|
|
90
|
-
? ["/onlyne", t("guideContacts"), t("guidePending"), t("guideAccept")]
|
|
91
|
-
: ["/onlyne", t("guideDiscover"), t("guideRequestExchange")],
|
|
92
|
-
}),
|
|
93
|
-
};
|
|
94
|
-
node = new OnlyneNode({
|
|
95
|
-
node_id: config.node.id, base_url: deps.endpoint, instance_id: deps.instanceId,
|
|
96
|
-
now: () => Date.now(), id: () => crypto.randomUUID(),
|
|
97
|
-
authentication: keys === undefined ? ["none"] : ["signature"], plugins: {
|
|
98
|
-
storage: deps.storage, authenticate, resolve, decide: deps.decide, runtime: deps.runtime, maintenanceClock: deps.clock,
|
|
99
|
-
...(keys === undefined ? {} : { keys }),
|
|
100
|
-
...(signing === undefined ? {} : { transport: new FetchTransport(resolve, fetch, signing) }),
|
|
101
|
-
...(config.trust === undefined ? {} : { trust_seeds: config.trust }),
|
|
102
|
-
domainCandidates: id => node.discovery.candidates(id),
|
|
103
|
-
// The keyless development binding checks no identity (RFC 0001 §1): every declared
|
|
104
|
-
// actor may open a thread. A keyed node admits only peers whose exchange completed.
|
|
105
|
-
mayAdmit: async (actor, _thread, context) => keys === undefined || await admitted(actor, context),
|
|
106
|
-
inboundThread: async (actor, context) => keys === undefined || await admitted(actor, context),
|
|
107
|
-
// A context that cannot answer is not a continuation target: publishing one lets a
|
|
108
|
-
// sender aim a message at a session that can never reply (caerulea report 4, problem 7).
|
|
109
|
-
executionContexts: async () => (await deps.contexts.list()).filter(canAnswer)
|
|
110
|
-
.map(record => ({ alias: record.alias, summary: record.summary, state: record.state })) as Json,
|
|
111
|
-
mention: async alias => {
|
|
112
|
-
const name = alias.replace(/^@/, "");
|
|
113
|
-
if (name === "self" || name === config.node.id) return config.node.id;
|
|
114
|
-
const matches = (await node.discovery.listRecords()).filter(record => record.node_id === name || record.node_id.split(":").at(-1) === name);
|
|
115
|
-
const ids = [...new Set(matches.map(record => record.node_id))];
|
|
116
|
-
if (ids.length !== 1) throw new OnlyneError(ids.length ? "ambiguous_target" : "not_found", t("mentionNotFound", { name }));
|
|
117
|
-
return ids[0]!;
|
|
118
|
-
},
|
|
119
|
-
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,
|
|
120
|
-
// The same rows onlyne://neighbors renders: every observed node with its trust state.
|
|
121
|
-
neighbors: async () => {
|
|
122
|
-
const records = await node.discovery.listRecords();
|
|
123
|
-
const trust = new Map((await node.trust.list(node.auth)).map(entry => [entry.node_id, entry]));
|
|
124
|
-
return records.map(record => {
|
|
125
|
-
const entry = trust.get(record.node_id);
|
|
126
|
-
return {
|
|
127
|
-
node_id: record.node_id, base_url: record.base_url,
|
|
128
|
-
...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
|
|
129
|
-
...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
|
|
130
|
-
verified: record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now(),
|
|
131
|
-
phase: entry === undefined ? "no_exchange" : trustPhase(entry),
|
|
132
|
-
fingerprint: entry?.fingerprint ?? null,
|
|
133
|
-
};
|
|
134
|
-
});
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
return node;
|
|
139
|
-
}
|