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/drafts.ts
CHANGED
|
@@ -11,11 +11,17 @@ interface Draft {
|
|
|
11
11
|
prepared: PreparedSend;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** How a caller names the draft it wants sent: by id, or by the message it answers. */
|
|
15
|
+
export interface DraftRequest {
|
|
16
|
+
id?: string;
|
|
17
|
+
in_reply_to?: Json;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export async function createDraft(runtime: PluginRuntime, ctx: ExtensionContext, input: MsgInput, signal?: AbortSignal): Promise<unknown> {
|
|
15
21
|
const meta = { ...input.meta };
|
|
16
22
|
delete meta.op;
|
|
17
23
|
if (meta.relation !== "reply" && meta.relation !== "followup") throw new OnlyneError("invalid_request", t("draftNeedsRelation"));
|
|
18
|
-
const original = await
|
|
24
|
+
const original = await sourceReference(runtime, meta.in_reply_to);
|
|
19
25
|
if (ctx.agent.kind === "sub") {
|
|
20
26
|
const authorized = (await runtime.contexts.list()).some(record => record.agent_id === ctx.agent.id
|
|
21
27
|
&& record.source?.sender === original.sender && record.source.id === original.id);
|
|
@@ -30,28 +36,71 @@ export async function createDraft(runtime: PluginRuntime, ctx: ExtensionContext,
|
|
|
30
36
|
in_reply_to: original, body: prepared.message.msg, state: "draft", send: { meta: { draft_id: draft.id } } };
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Sends a prepared reply. The caller may name the draft by id or by the message it answers; a
|
|
41
|
+
* call that names neither continues with the most recent draft this session created, because
|
|
42
|
+
* the draft already carries the one decision that must not be taken twice — its plan.
|
|
43
|
+
*/
|
|
44
|
+
export async function sendDraft(runtime: PluginRuntime, ctx: ExtensionContext, request: DraftRequest, signal?: AbortSignal): Promise<unknown> {
|
|
34
45
|
if (ctx.agent.kind === "sub") throw new OnlyneError("forbidden", t("draftChildCannotSend"));
|
|
35
|
-
const draft = await runtime
|
|
36
|
-
if (draft === undefined) throw new OnlyneError("not_found", t("draftNotFound", { id }));
|
|
46
|
+
const draft = await findDraft(runtime, ctx, request);
|
|
37
47
|
const { to, message, plan } = draft.prepared;
|
|
38
48
|
return runtime.node.client.send(to, message, plan, { context: ctx.agent.id, signal });
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Draft lookup. The index is (created_by, in_reply_to): a reference names the draft that
|
|
53
|
+
* answered that message, and a session with no reference of its own sends the newest draft it
|
|
54
|
+
* created. Several drafts can answer one message — a child drafted, then the main session
|
|
55
|
+
* answered something else — so the newest wins; the draft's own plan is what makes the choice
|
|
56
|
+
* safe, since no wording is decided again here.
|
|
57
|
+
*/
|
|
58
|
+
async function findDraft(runtime: PluginRuntime, ctx: ExtensionContext, request: DraftRequest): Promise<Draft> {
|
|
59
|
+
if (request.id !== undefined) {
|
|
60
|
+
const draft = await runtime.storage.transaction(tx => tx.get<Draft>(`draft/${request.id}`));
|
|
61
|
+
if (draft === undefined) throw new OnlyneError("not_found", t("draftNotFound", { id: request.id }));
|
|
62
|
+
return draft;
|
|
63
|
+
}
|
|
64
|
+
// The store is the only index: drafts outlive the session that created them, and a scan of
|
|
65
|
+
// a handful of rows costs less than a second index that could disagree with it.
|
|
66
|
+
const stored = await runtime.storage.transaction(tx => tx.scan<Draft>("draft/"));
|
|
67
|
+
const drafts = stored.map(row => row.value);
|
|
68
|
+
const wanted = request.in_reply_to === undefined ? undefined : await sourceReference(runtime, request.in_reply_to);
|
|
69
|
+
const matching = wanted === undefined
|
|
70
|
+
? drafts
|
|
71
|
+
: drafts.filter(draft => {
|
|
72
|
+
const ref = draft.prepared.message.meta?.in_reply_to;
|
|
73
|
+
return ref !== undefined && ref.sender === wanted.sender && ref.id === wanted.id;
|
|
74
|
+
});
|
|
75
|
+
// This session's own draft is preferred; a child's draft it was just handed still answers,
|
|
76
|
+
// because the reference, not the author, is what the caller named.
|
|
77
|
+
const own = matching.filter(draft => draft.created_by === ctx.agent.id);
|
|
78
|
+
const pool = wanted === undefined ? own : own.length > 0 ? own : matching;
|
|
79
|
+
const newest = pool.sort((left, right) =>
|
|
80
|
+
right.created_at.localeCompare(left.created_at) || right.id.localeCompare(left.id))[0];
|
|
81
|
+
if (newest !== undefined) return newest;
|
|
82
|
+
throw wanted === undefined
|
|
83
|
+
? new OnlyneError("not_found", t("draftNoRecent"))
|
|
84
|
+
: new OnlyneError("not_found", t("draftNoMatch", { sender: wanted.sender, id: wanted.id }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The message a draft answers, in the two shapes a caller can copy from a receipt or a read. */
|
|
88
|
+
async function sourceReference(runtime: PluginRuntime, value: Json | undefined): Promise<MessageRef> {
|
|
89
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
90
|
+
throw new OnlyneError("invalid_request", t("draftNeedsReference"));
|
|
91
|
+
}
|
|
92
|
+
const sender = typeof value.sender === "string" && value.sender.length > 0 ? value.sender : undefined;
|
|
93
|
+
const id = typeof value.id === "string" && value.id.length > 0 ? value.id : undefined;
|
|
94
|
+
if (sender !== undefined && id !== undefined) {
|
|
95
|
+
return { sender: await runtime.decider.refs.peerId(sender).catch(() => sender), id };
|
|
96
|
+
}
|
|
97
|
+
const thread = typeof value.thread === "string" && value.thread.length > 0 ? value.thread : undefined;
|
|
98
|
+
const seq = typeof value.seq === "number" ? String(value.seq) : typeof value.seq === "string" && value.seq.length > 0 ? value.seq : undefined;
|
|
99
|
+
if (thread !== undefined && seq !== undefined) {
|
|
100
|
+
const ref = await runtime.decider.refs.threadByAlias(thread);
|
|
101
|
+
const entry = await runtime.decider.refs.entryAt(ref, seq);
|
|
102
|
+
if (entry !== undefined) return { sender: entry.message.sender, id: entry.message.id };
|
|
103
|
+
throw new OnlyneError("not_found", t("draftSourceUnavailable"));
|
|
55
104
|
}
|
|
56
|
-
throw new OnlyneError("
|
|
105
|
+
throw new OnlyneError("invalid_request", t("draftNeedsReference"));
|
|
57
106
|
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { OnlyneError, type Action, type Json, type NodeId } from "@onlyne/lib";
|
|
2
|
+
import type { ContextRegistry } from "@onlyne/node-host/store";
|
|
3
|
+
import type { ContextRecord } from "@onlyne/node-host/types";
|
|
4
|
+
import { text } from "./decider-text.ts";
|
|
5
|
+
import { resourceFields } from "./plan.ts";
|
|
6
|
+
import { threadKey } from "./resolve.ts";
|
|
7
|
+
|
|
8
|
+
/** The five intents node O-1 chooses among. */
|
|
9
|
+
export type OutboundIntent = "communicate" | "inspect" | "manage_thread" | "manage_domain" | "operate_resource";
|
|
10
|
+
|
|
11
|
+
export interface IntentSettlement {
|
|
12
|
+
intent: OutboundIntent;
|
|
13
|
+
/** Set when a judged intent could not execute and a deterministic rule replaced it. */
|
|
14
|
+
override?: { chosen: OutboundIntent; fallback: OutboundIntent; reason: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A call that names a recipient: `meta.to`, or the message it answers. */
|
|
18
|
+
function addressed(meta: Record<string, Json> | undefined): boolean {
|
|
19
|
+
if (meta === undefined) return false;
|
|
20
|
+
if (typeof meta.to === "string" && meta.to.length > 0) return true;
|
|
21
|
+
return meta.in_reply_to !== null && typeof meta.in_reply_to === "object" && !Array.isArray(meta.in_reply_to);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A resource intent is executable only when the fields it needs are present. The O-1 criteria
|
|
26
|
+
* already say a message that merely mentions a file is not a resource operation, but a criterion
|
|
27
|
+
* is prose the model may violate; this is the same rule in code.
|
|
28
|
+
*
|
|
29
|
+
* When a message names an explicit recipient and carries no complete resource action, the
|
|
30
|
+
* resource choice is a misread of a communication: letting it through can only fail on missing
|
|
31
|
+
* fields, and the error would send the caller to add `meta.resource` — turning a legitimate
|
|
32
|
+
* message into an operation on somebody's resource (issue: outbound-intent-resource-false-
|
|
33
|
+
* positive). With no recipient named the resource branch keeps its own hard error, because
|
|
34
|
+
* nothing in the call says the message was meant for anyone.
|
|
35
|
+
*/
|
|
36
|
+
export function settleIntent(intent: OutboundIntent, meta: Record<string, Json> | undefined): IntentSettlement {
|
|
37
|
+
if (intent !== "operate_resource") return { intent };
|
|
38
|
+
if (resourceFields(meta) !== undefined) return { intent };
|
|
39
|
+
if (!addressed(meta)) return { intent };
|
|
40
|
+
return {
|
|
41
|
+
intent: "communicate",
|
|
42
|
+
override: {
|
|
43
|
+
chosen: "operate_resource",
|
|
44
|
+
fallback: "communicate",
|
|
45
|
+
reason: "the message names a recipient and carries no complete meta.resource",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A subagent may read only the threads its own context records authorize on this node. The
|
|
52
|
+
* unfiltered node-wide views stay with the main session.
|
|
53
|
+
*/
|
|
54
|
+
export class ActionGuard {
|
|
55
|
+
constructor(private readonly deps: { contexts: ContextRegistry; nodeId: NodeId }) {}
|
|
56
|
+
|
|
57
|
+
async assert(scope: { readOnly?: boolean; agentId: string }, action: Action): Promise<void> {
|
|
58
|
+
if (scope.readOnly !== true) return;
|
|
59
|
+
if (action.kind !== "read") {
|
|
60
|
+
throw new OnlyneError("forbidden", text("error.guard_write"));
|
|
61
|
+
}
|
|
62
|
+
if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
|
|
63
|
+
if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
|
|
64
|
+
throw new OnlyneError("forbidden", text("error.guard_node_listing"));
|
|
65
|
+
}
|
|
66
|
+
const records = await this.authorized(scope.agentId);
|
|
67
|
+
if (action.scope === "evidence") {
|
|
68
|
+
const ref = action.message;
|
|
69
|
+
const tied = ref !== undefined && records.some(record =>
|
|
70
|
+
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
71
|
+
);
|
|
72
|
+
if (!tied) throw new OnlyneError("forbidden", text("error.guard_evidence"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// `{sender,id}` names no thread, so the record that received that message is the
|
|
76
|
+
// authorization — the same tie evidence uses.
|
|
77
|
+
if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
|
|
78
|
+
const ref = action.message;
|
|
79
|
+
const tied = records.some(record =>
|
|
80
|
+
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
81
|
+
);
|
|
82
|
+
if (!tied) throw new OnlyneError("forbidden", text("error.guard_message"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action.thread === undefined) {
|
|
86
|
+
throw new OnlyneError("forbidden", text("error.guard_thread_required"));
|
|
87
|
+
}
|
|
88
|
+
const key = threadKey(action.thread);
|
|
89
|
+
if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
|
|
90
|
+
throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async authorized(agentId: string): Promise<ContextRecord[]> {
|
|
95
|
+
let records: ContextRecord[];
|
|
96
|
+
try {
|
|
97
|
+
records = await this.deps.contexts.list();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new OnlyneError("unavailable", text("error.registry_unreadable", {
|
|
100
|
+
error: error instanceof Error ? error.message : String(error),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
return records.filter(record =>
|
|
104
|
+
record.node_id === this.deps.nodeId && record.agent_id === agentId && record.thread_ref !== undefined,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/handshake.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { OnlyneError, type Json, type KeyExchange, type Message, type NodeId, type Storage, type ThreadRef, type TrustEntry, type TrustPhase } from "@onlyne/lib";
|
|
2
|
+
import { evaluateAcquisition, type AcquireInput, type AcquireVerdict, type AcquisitionPolicy } from "@onlyne/node-host/acquire";
|
|
3
|
+
import { bridgeText as t } from "./bridge-text.ts";
|
|
4
|
+
import { hostText } from "@onlyne/node-host/host-text";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RFC 0018 §3.2: the handshake entries live in one thread this node owns alone, so nothing in
|
|
8
|
+
* them ever leaves the node, and the business thread tree stays a record of conversations
|
|
9
|
+
* rather than a log of system events.
|
|
10
|
+
*/
|
|
11
|
+
export const TRUST_THREAD = "trust";
|
|
12
|
+
|
|
13
|
+
export function trustThread(nodeId: NodeId): ThreadRef {
|
|
14
|
+
return { owner: nodeId, id: TRUST_THREAD };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isTrustThread(nodeId: NodeId, ref: ThreadRef | undefined): boolean {
|
|
18
|
+
return ref !== undefined && ref.owner === nodeId && ref.id === TRUST_THREAD;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** RFC 0018 §2.1: where one peer statement's connection arrived from. */
|
|
22
|
+
export interface StatementObservation {
|
|
23
|
+
/** The address the connection came from; absent when this host could not observe one. */
|
|
24
|
+
observed?: string;
|
|
25
|
+
at: string;
|
|
26
|
+
cause: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const OBSERVED = "acquire/observed/";
|
|
30
|
+
const DELIVERED = "acquire/delivered/";
|
|
31
|
+
|
|
32
|
+
function observedKey(nodeId: NodeId, fingerprint: string): string {
|
|
33
|
+
return `${OBSERVED}${encodeURIComponent(nodeId)}/${encodeURIComponent(fingerprint)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deliveredKey(nodeId: NodeId, fingerprint: string, phase: TrustPhase): string {
|
|
37
|
+
return `${DELIVERED}${encodeURIComponent(nodeId)}/${encodeURIComponent(fingerprint)}/${phase}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* RFC 0018 §2.1/§3: the two durable records this host keeps about a handshake. The observed
|
|
42
|
+
* address is written for every statement, before any verdict, so an approval decided later
|
|
43
|
+
* reads the address that statement itself arrived from and never the address of the call that
|
|
44
|
+
* asks (§2.1). The delivered records are one per `(node_id, fingerprint, phase)`, so a
|
|
45
|
+
* replayed event appends no second entry.
|
|
46
|
+
*/
|
|
47
|
+
export class AcquisitionLog {
|
|
48
|
+
constructor(private readonly storage: Storage) {}
|
|
49
|
+
|
|
50
|
+
async observe(nodeId: NodeId, fingerprint: string, observation: StatementObservation): Promise<void> {
|
|
51
|
+
await this.storage.transaction(tx => tx.put(observedKey(nodeId, fingerprint), observation));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A local transition is still an observed trust event, but it must not replace the address
|
|
56
|
+
* of the peer statement. Keep its timestamp and cause beside the original address instead.
|
|
57
|
+
*/
|
|
58
|
+
async transition(nodeId: NodeId, fingerprint: string, at: string, cause: string): Promise<void> {
|
|
59
|
+
await this.storage.transaction(async tx => {
|
|
60
|
+
const key = observedKey(nodeId, fingerprint);
|
|
61
|
+
const current = await tx.get<StatementObservation>(key);
|
|
62
|
+
await tx.put(key, { ...(current?.observed === undefined ? {} : { observed: current.observed }), at, cause });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async observation(nodeId: NodeId, fingerprint: string): Promise<StatementObservation | undefined> {
|
|
67
|
+
return this.storage.transaction(tx => tx.get<StatementObservation>(observedKey(nodeId, fingerprint)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async delivered(nodeId: NodeId, fingerprint: string, phase: TrustPhase): Promise<boolean> {
|
|
71
|
+
return (await this.storage.transaction(tx => tx.get(deliveredKey(nodeId, fingerprint, phase)))) !== undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async markDelivered(nodeId: NodeId, fingerprint: string, phase: TrustPhase, at: string): Promise<void> {
|
|
75
|
+
await this.storage.transaction(tx => tx.put(deliveredKey(nodeId, fingerprint, phase), { node_id: nodeId, fingerprint, phase, at }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* RFC 0018 §3: the reports an agent already acted on are settled. A revocation names no
|
|
80
|
+
* fingerprint, so it settles every report held for the node; a later statement opens its own.
|
|
81
|
+
*/
|
|
82
|
+
async settle(nodeId: NodeId, fingerprint?: string): Promise<void> {
|
|
83
|
+
const prefix = `${DELIVERED}${encodeURIComponent(nodeId)}/${fingerprint === undefined ? "" : `${encodeURIComponent(fingerprint)}/`}`;
|
|
84
|
+
await this.storage.transaction(async tx => {
|
|
85
|
+
for (const row of await tx.scan(prefix)) await tx.delete(row.key);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The machine facts a handshake entry carries beside its body (RFC 0018 §3). */
|
|
91
|
+
export interface HandshakeFacts {
|
|
92
|
+
node_id: NodeId;
|
|
93
|
+
fingerprint: string;
|
|
94
|
+
phase: TrustPhase;
|
|
95
|
+
/** Whether this entry owes an agent decision; a status update owes none. */
|
|
96
|
+
pending: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The facts one handshake entry carries, or undefined for an entry that is not one. */
|
|
100
|
+
export function handshakeFacts(meta: Message["meta"] | undefined): HandshakeFacts | undefined {
|
|
101
|
+
const value = meta?.data?.trust;
|
|
102
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
103
|
+
const row = value as Record<string, Json>;
|
|
104
|
+
const { node_id: node, fingerprint, phase, pending } = row;
|
|
105
|
+
if (typeof node !== "string" || typeof fingerprint !== "string" || typeof pending !== "boolean") return undefined;
|
|
106
|
+
if (phase !== "invited" && phase !== "requested" && phase !== "exchanged" && phase !== "revoked") return undefined;
|
|
107
|
+
return { node_id: node, fingerprint, phase, pending };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HandshakeInput {
|
|
111
|
+
node_id: NodeId;
|
|
112
|
+
fingerprint: string;
|
|
113
|
+
phase: TrustPhase;
|
|
114
|
+
/** The address the peer wrote into its own statement. */
|
|
115
|
+
declared: string;
|
|
116
|
+
/** The address that statement arrived from; absent when this host observed none. */
|
|
117
|
+
observed?: string;
|
|
118
|
+
policy: AcquisitionPolicy;
|
|
119
|
+
/** The judgement made for this event, when one was made. */
|
|
120
|
+
judgement?: ApprovalJudgement;
|
|
121
|
+
/** Whether an approval was committed without an agent deciding first. */
|
|
122
|
+
approved: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* RFC 0018 §3: one handshake entry. The body carries what the judgement read and concluded;
|
|
127
|
+
* the action identifiers appear only when a decision is actually owed, and a status update
|
|
128
|
+
* says so instead. `pending` is the same fact for the host's todo hook.
|
|
129
|
+
*/
|
|
130
|
+
export function handshakeEntry(input: HandshakeInput): { msg: string; trust: { [key: string]: Json } } {
|
|
131
|
+
// RFC 0018 §3: only an invitation this host did not approve itself is work. A pending local
|
|
132
|
+
// approval waits on the peer, so there is nothing to do and nothing to wake a turn for.
|
|
133
|
+
const pending = input.phase === "invited" && !input.approved;
|
|
134
|
+
const lines = [
|
|
135
|
+
t("trustHeader", { phase: input.phase, node: input.node_id }),
|
|
136
|
+
t("trustFingerprint", { fingerprint: input.fingerprint }),
|
|
137
|
+
t("trustDeclared", { declared: input.declared }),
|
|
138
|
+
t("trustObserved", { observed: input.observed ?? t("trustUnobserved") }),
|
|
139
|
+
t("trustPolicy", { policy: input.policy.policy }),
|
|
140
|
+
];
|
|
141
|
+
if (input.judgement !== undefined) {
|
|
142
|
+
lines.push(t("trustJudgement", {
|
|
143
|
+
reason: input.judgement.verdict.reason,
|
|
144
|
+
conclusion: input.judgement.verdict.allowed ? t("trustAllowed") : t("trustRefused"),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
if (input.phase === "invited") lines.push(input.approved ? t("trustApproved") : t("trustInvited"));
|
|
148
|
+
else if (input.phase === "requested") lines.push(t("trustRequested"));
|
|
149
|
+
else if (input.phase === "exchanged") lines.push(t("trustExchanged"));
|
|
150
|
+
else lines.push(t("trustRevoked"));
|
|
151
|
+
if (input.phase === "invited" && !input.approved) {
|
|
152
|
+
// RFC 0018 §3: under `none` the agent may only look. Offering an action the policy
|
|
153
|
+
// refuses would ask for a decision that can never be made.
|
|
154
|
+
lines.push(input.policy.policy === "none"
|
|
155
|
+
? t("trustPanelOnly")
|
|
156
|
+
: t("trustAcceptAction", { node: input.node_id, fingerprint: input.fingerprint }));
|
|
157
|
+
}
|
|
158
|
+
if (pending) lines.push(t("trustRevokeAction", { node: input.node_id }));
|
|
159
|
+
return { msg: lines.join("\n"), trust: { node_id: input.node_id, fingerprint: input.fingerprint, phase: input.phase, pending } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** RFC 0018 §2: one approval judgement and every input it read. */
|
|
163
|
+
export interface ApprovalJudgement {
|
|
164
|
+
at: string;
|
|
165
|
+
node_id: NodeId;
|
|
166
|
+
/** Absent when no fingerprint has been proved yet: an initiation reads the peer's key after the gate. */
|
|
167
|
+
fingerprint?: string;
|
|
168
|
+
policy: AcquisitionPolicy;
|
|
169
|
+
input: AcquireInput;
|
|
170
|
+
verdict: AcquireVerdict;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The judgement as the audit line records it: the time, the peer, and every input the verdict read. */
|
|
174
|
+
export function judgementFacts(judgement: ApprovalJudgement): Record<string, unknown> {
|
|
175
|
+
const { input, verdict } = judgement;
|
|
176
|
+
return {
|
|
177
|
+
at: judgement.at,
|
|
178
|
+
node: judgement.node_id,
|
|
179
|
+
fingerprint: judgement.fingerprint ?? null,
|
|
180
|
+
policy: judgement.policy.policy,
|
|
181
|
+
allow: [...judgement.policy.allow],
|
|
182
|
+
observed: input.observed ?? null,
|
|
183
|
+
declared: input.declared ?? null,
|
|
184
|
+
recorded: [...(input.recorded ?? [])],
|
|
185
|
+
allowed: verdict.allowed,
|
|
186
|
+
reason: verdict.reason,
|
|
187
|
+
...(verdict.matched === undefined ? {} : { matched: verdict.matched }),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Everything the judgement reads from the host, and the two calls it may then commit. */
|
|
192
|
+
export interface ApprovalDeps {
|
|
193
|
+
policy: AcquisitionPolicy;
|
|
194
|
+
log: AcquisitionLog;
|
|
195
|
+
/** RFC 0018 §2.1: the address the peer's last statement arrived from, as this host recorded it. */
|
|
196
|
+
observed(nodeId: NodeId, fingerprint: string): Promise<string | undefined>;
|
|
197
|
+
/** The address the peer wrote into its own statement, never the address of this call. */
|
|
198
|
+
declared(nodeId: NodeId): Promise<string | undefined>;
|
|
199
|
+
/** Every discovery address recorded for this node id; each is an outbound candidate (§2.2). */
|
|
200
|
+
recorded(nodeId: NodeId): Promise<string[]>;
|
|
201
|
+
/** The audit line, written whether or not the verdict allows the approval (§4.1). */
|
|
202
|
+
audit(judgement: ApprovalJudgement): void;
|
|
203
|
+
accept(nodeId: NodeId, fingerprint: string): Promise<TrustEntry>;
|
|
204
|
+
revoke(nodeId: NodeId): Promise<void>;
|
|
205
|
+
now(): string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ApprovalRequest {
|
|
209
|
+
node_id: NodeId;
|
|
210
|
+
/** The fingerprint the peer proved. Omitted by an initiation, whose key read comes after the gate. */
|
|
211
|
+
fingerprint?: string;
|
|
212
|
+
/** The statement's own connection address, when the caller is holding it. */
|
|
213
|
+
observed?: string;
|
|
214
|
+
/**
|
|
215
|
+
* The discovery addresses as they stood when the statement arrived (RFC 0018 §2.2). A record
|
|
216
|
+
* this host derives from the statement itself is not part of the judgement.
|
|
217
|
+
*/
|
|
218
|
+
recorded?: string[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* RFC 0018 §2: the one judgement, read by both the agent path and the auto-approval path.
|
|
223
|
+
* `observed` is the address a recorded statement arrived from — the caller's live connection
|
|
224
|
+
* address only when it is judging the statement it just received — and the declared address
|
|
225
|
+
* and every recorded address are checked with it (§2.1–2.2).
|
|
226
|
+
*/
|
|
227
|
+
export async function judgeApproval(deps: ApprovalDeps, request: ApprovalRequest): Promise<ApprovalJudgement> {
|
|
228
|
+
const observed = request.observed
|
|
229
|
+
?? (request.fingerprint === undefined ? undefined : await deps.observed(request.node_id, request.fingerprint));
|
|
230
|
+
const declared = await deps.declared(request.node_id);
|
|
231
|
+
const input: AcquireInput = {
|
|
232
|
+
...(observed === undefined || observed === "" ? {} : { observed }),
|
|
233
|
+
...(declared === undefined || declared === "" ? {} : { declared }),
|
|
234
|
+
recorded: request.recorded ?? await deps.recorded(request.node_id),
|
|
235
|
+
};
|
|
236
|
+
const judgement: ApprovalJudgement = {
|
|
237
|
+
at: deps.now(), node_id: request.node_id, fingerprint: request.fingerprint,
|
|
238
|
+
policy: deps.policy, input, verdict: await evaluateAcquisition(deps.policy, input),
|
|
239
|
+
};
|
|
240
|
+
deps.audit(judgement);
|
|
241
|
+
return judgement;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The refusal an agent-initiated approval gets. A refused approval is not a revocation: the peer's row is left as it is. */
|
|
245
|
+
export function approvalRefused(judgement: ApprovalJudgement): OnlyneError {
|
|
246
|
+
const { policy, verdict } = judgement;
|
|
247
|
+
return new OnlyneError("forbidden", hostText("acquireDenied", { policy: policy.policy, reason: verdict.reason }), {
|
|
248
|
+
policy_denied: verdict.reason,
|
|
249
|
+
policy: policy.policy,
|
|
250
|
+
...(judgement.input.observed === undefined ? {} : { observed: judgement.input.observed }),
|
|
251
|
+
...(judgement.input.declared === undefined ? {} : { declared: judgement.input.declared }),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** RFC 0018 §2/§3.1: the agent's `trust_accept`, judged before anything is committed. */
|
|
256
|
+
export async function approvePeer(deps: ApprovalDeps, nodeId: NodeId, fingerprint: string): Promise<TrustEntry> {
|
|
257
|
+
const judgement = await judgeApproval(deps, { node_id: nodeId, fingerprint });
|
|
258
|
+
if (!judgement.verdict.allowed) throw approvalRefused(judgement);
|
|
259
|
+
const entry = await deps.accept(nodeId, fingerprint);
|
|
260
|
+
await deps.log.settle(nodeId, fingerprint);
|
|
261
|
+
return entry;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* RFC 0018 §2/§3.1: the agent's `trust_begin`. The judgement reads stored facts only and runs
|
|
266
|
+
* before `exchange`, which is this path's first outbound request — the key read that starts the
|
|
267
|
+
* exchange — so a refusal sends nothing and leaves the peer's row untouched. On an allow the
|
|
268
|
+
* library commits the local approval and mails the statement, and the entry it returns carries
|
|
269
|
+
* the phase that commit produced.
|
|
270
|
+
*/
|
|
271
|
+
export async function beginPeer(
|
|
272
|
+
deps: ApprovalDeps,
|
|
273
|
+
exchange: (nodeId: NodeId) => Promise<KeyExchange>,
|
|
274
|
+
request: ApprovalRequest,
|
|
275
|
+
): Promise<{ session: KeyExchange; entry: TrustEntry }> {
|
|
276
|
+
const judgement = await judgeApproval(deps, request);
|
|
277
|
+
if (!judgement.verdict.allowed) throw approvalRefused(judgement);
|
|
278
|
+
const session = await exchange(request.node_id);
|
|
279
|
+
const entry = await session.confirm(session.fingerprint);
|
|
280
|
+
await deps.log.settle(request.node_id, entry.fingerprint);
|
|
281
|
+
return { session, entry };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* RFC 0018 §2: the agent's `trust_revoke`, never judged. The policy decides who may come in;
|
|
286
|
+
* it has no say over who may be turned away, which is also why this path reads no address.
|
|
287
|
+
*/
|
|
288
|
+
export async function revokePeer(deps: ApprovalDeps, nodeId: NodeId): Promise<void> {
|
|
289
|
+
await deps.revoke(nodeId);
|
|
290
|
+
await deps.log.settle(nodeId);
|
|
291
|
+
}
|