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/resolve.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OnlyneError,
|
|
3
|
+
type Entry,
|
|
4
|
+
type Json,
|
|
5
|
+
type JoinedThread,
|
|
6
|
+
type MessageRef,
|
|
7
|
+
type NodeId,
|
|
8
|
+
type Page,
|
|
9
|
+
type Thread,
|
|
10
|
+
type ThreadRef,
|
|
11
|
+
} from "@onlyne/lib";
|
|
12
|
+
import { text } from "./decider-text.ts";
|
|
13
|
+
|
|
14
|
+
/** Upper bound on pages scanned for one thread lookup; exceeding it is reported, never hidden. */
|
|
15
|
+
const MAX_PAGES = 100;
|
|
16
|
+
|
|
17
|
+
/** One discovered peer, as the library's discovery records it. */
|
|
18
|
+
export interface PeerRecord {
|
|
19
|
+
node_id: NodeId;
|
|
20
|
+
base_url: string;
|
|
21
|
+
instance_id: string;
|
|
22
|
+
last_seen: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The records every textual reference is resolved against. */
|
|
26
|
+
export interface ReferenceDeps {
|
|
27
|
+
config: { node: { id: NodeId } };
|
|
28
|
+
log?(message: string, fields: Record<string, unknown>): void;
|
|
29
|
+
listThreads(): Promise<Thread[]>;
|
|
30
|
+
listJoinedThreads(): Promise<JoinedThread[]>;
|
|
31
|
+
listMessages(thread: ThreadRef, cursor?: string): Promise<Page<Entry>>;
|
|
32
|
+
listPeers(): Promise<PeerRecord[]>;
|
|
33
|
+
listTrust(): Promise<Array<{ node_id: NodeId; phase: string; base_url?: string }>>;
|
|
34
|
+
listInbox(): Promise<Array<{ context?: string; attention: { entry?: Entry; disposition?: string } }>>;
|
|
35
|
+
threadMembers(thread: ThreadRef): Promise<NodeId[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One reply reference: the message it addresses and the node that owns the answer. */
|
|
39
|
+
export interface ReplyReference {
|
|
40
|
+
ref: MessageRef;
|
|
41
|
+
authority: NodeId;
|
|
42
|
+
thread_ref?: ThreadRef;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every reference one send names, resolved before its plan is committed. */
|
|
46
|
+
export interface SendReferences {
|
|
47
|
+
target?: NodeId;
|
|
48
|
+
thread?: ThreadRef;
|
|
49
|
+
mentions?: string[];
|
|
50
|
+
reply?: ReplyReference;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stringField(record: Record<string, Json>, key: string): string | undefined {
|
|
54
|
+
const value = record[key];
|
|
55
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recordField(record: Record<string, Json>, key: string): Record<string, Json> | undefined {
|
|
59
|
+
const value = record[key];
|
|
60
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, Json>) : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A sequence number as the model writes it: `"1"` and `1` both mean the same seq. The judge
|
|
65
|
+
* schema says `record(string, unknown)`, so nothing tells the caller to quote it (caerulea
|
|
66
|
+
* report 3, problem 2).
|
|
67
|
+
*/
|
|
68
|
+
function seqField(record: Record<string, Json>, key: string): string | undefined {
|
|
69
|
+
const value = record[key];
|
|
70
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
71
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function message(error: unknown): string {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `owner/id` across both trees: one canonical spelling for a thread reference. */
|
|
80
|
+
export function threadKey(ref: ThreadRef): string {
|
|
81
|
+
return `${ref.owner}/${ref.id}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A node id's last URN segment; plain ids are their own segment. */
|
|
85
|
+
export function lastSegment(nodeId: NodeId): string {
|
|
86
|
+
const trimmed = nodeId.replace(/\/+$/, "");
|
|
87
|
+
const parts = trimmed.split(/[:/]/).filter(part => part.length > 0);
|
|
88
|
+
return parts.at(-1) ?? trimmed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The thread a send continues: the most recently opened open thread this node owns whose members
|
|
93
|
+
* already include the addressee. Derived from local records alone (RFC 0016 §5), so it costs no
|
|
94
|
+
* judgement. A node may hold several open threads with one peer; the newest is the conversation
|
|
95
|
+
* still in use, and a caller that wants a fresh topic names one or plans `execution: new`.
|
|
96
|
+
*
|
|
97
|
+
* Only a thread holding this node and the addressee continues: a thread with a third member is a
|
|
98
|
+
* group conversation, and folding a private message into it would put a body in front of every
|
|
99
|
+
* member. A group is addressed by naming it, which is why `meta.thread` wins over this lookup.
|
|
100
|
+
*/
|
|
101
|
+
export async function reuseThread(deps: ReferenceDeps, to: NodeId): Promise<ThreadRef | undefined> {
|
|
102
|
+
const self = deps.config.node.id;
|
|
103
|
+
const threads = await deps.listThreads();
|
|
104
|
+
const candidates = threads
|
|
105
|
+
.filter(thread => thread.thread_ref.owner === self && thread.state === "open")
|
|
106
|
+
.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
|
107
|
+
for (const thread of candidates) {
|
|
108
|
+
let members: NodeId[];
|
|
109
|
+
try {
|
|
110
|
+
members = await deps.threadMembers(thread.thread_ref);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// An unreadable member list is a reason to try the next thread, not to open a new one
|
|
113
|
+
// silently, so it is reported and skipped.
|
|
114
|
+
deps.log?.("onlyne.thread.members_unreadable", { thread: threadKey(thread.thread_ref), error: message(error) });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (members.includes(to) && members.every(member => member === self || member === to)) return thread.thread_ref;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every peer this node can name, from both sources that know one.
|
|
124
|
+
*
|
|
125
|
+
* A trust row is identity: the peer exists whether or not anyone answered a probe recently. A
|
|
126
|
+
* discovery record is an address, and a live one wins over the address a statement declared.
|
|
127
|
+
* Callers that ask "who is this" and callers that ask "who may receive this" must not diverge,
|
|
128
|
+
* or the same peer resolves for `meta.to` and vanishes from the target question — the shape of
|
|
129
|
+
* the reported `No discovered node is named …` failure.
|
|
130
|
+
*/
|
|
131
|
+
export async function knownPeers(deps: ReferenceDeps): Promise<PeerRecord[]> {
|
|
132
|
+
const byId = new Map<NodeId, PeerRecord>();
|
|
133
|
+
let readable = false;
|
|
134
|
+
let failure = "";
|
|
135
|
+
try {
|
|
136
|
+
for (const row of await deps.listTrust()) {
|
|
137
|
+
readable = true;
|
|
138
|
+
if (row.phase === "revoked") continue;
|
|
139
|
+
byId.set(row.node_id, { node_id: row.node_id, base_url: row.base_url ?? "", instance_id: "trust", last_seen: "" });
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
failure = message(error);
|
|
143
|
+
deps.log?.("onlyne.judge.trust_unreadable", { error: failure });
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
for (const peer of await deps.listPeers()) {
|
|
147
|
+
readable = true;
|
|
148
|
+
byId.set(peer.node_id, peer);
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failure = message(error);
|
|
152
|
+
deps.log?.("onlyne.judge.peers_unreadable", { error: failure });
|
|
153
|
+
}
|
|
154
|
+
if (!readable) throw new OnlyneError("unavailable", text("reason.discovery_unreadable", { error: failure }));
|
|
155
|
+
return [...byId.values()].filter(peer => peer.node_id !== deps.config.node.id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The single place a textual reference becomes a record: `meta.to` and `meta.mentions` through
|
|
160
|
+
* node identity, `meta.thread` through owned or joined thread evidence, and `meta.in_reply_to`
|
|
161
|
+
* and `meta.message` through the thread that holds the message. Every entry point of a decision
|
|
162
|
+
* resolves here, so the same spelling cannot mean two things on two paths (audit §2.7).
|
|
163
|
+
*/
|
|
164
|
+
export class References {
|
|
165
|
+
constructor(private readonly deps: ReferenceDeps) {}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A send's references, resolved in one pass before its plan is committed. Only the fields
|
|
169
|
+
* the call carries are read, so a message that names no thread costs no thread lookup.
|
|
170
|
+
*/
|
|
171
|
+
async forSend(meta: Record<string, Json> | undefined): Promise<SendReferences> {
|
|
172
|
+
const reply = await this.reply(meta);
|
|
173
|
+
const target = await this.target(meta);
|
|
174
|
+
const thread = await this.thread(meta);
|
|
175
|
+
const mentions = await this.mentions(meta);
|
|
176
|
+
return {
|
|
177
|
+
...(target === undefined ? {} : { target }),
|
|
178
|
+
...(thread === undefined ? {} : { thread }),
|
|
179
|
+
...(mentions === undefined ? {} : { mentions }),
|
|
180
|
+
...(reply === undefined ? {} : { reply }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `meta.to`, resolved against the same peer set every other reference uses. */
|
|
185
|
+
async target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
|
|
186
|
+
if (meta === undefined) return undefined;
|
|
187
|
+
const value = meta.to;
|
|
188
|
+
if (value === undefined) return undefined;
|
|
189
|
+
if (typeof value !== "string") throw new OnlyneError("invalid_request", text("error.meta_to_single"));
|
|
190
|
+
if (value.length === 0) throw new OnlyneError("invalid_request", text("error.meta_to_empty"));
|
|
191
|
+
return this.peerId(value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async peerId(alias: string): Promise<NodeId> {
|
|
195
|
+
const ids = await this.knownIds();
|
|
196
|
+
if (alias === "self") return this.deps.config.node.id;
|
|
197
|
+
if (ids.includes(alias)) return alias;
|
|
198
|
+
const matches = ids.filter(id => lastSegment(id) === alias);
|
|
199
|
+
if (matches.length === 1) return matches[0]!;
|
|
200
|
+
if (matches.length > 1) {
|
|
201
|
+
throw new OnlyneError("ambiguous_target", text("error.alias_ambiguous", { alias, matches: matches.join(", ") }));
|
|
202
|
+
}
|
|
203
|
+
throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Every node this one can name: itself, each peer it holds a live trust row with, and each
|
|
208
|
+
* peer discovery currently reports. The trust rows come first because they are the ones that
|
|
209
|
+
* survive a peer moving, restarting or dropping out of the catalog for a refresh window.
|
|
210
|
+
*/
|
|
211
|
+
private async knownIds(): Promise<NodeId[]> {
|
|
212
|
+
const ids = new Set<NodeId>([this.deps.config.node.id]);
|
|
213
|
+
try {
|
|
214
|
+
for (const peer of await knownPeers(this.deps)) ids.add(peer.node_id);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// Both sources failing leaves only this node; an alias then resolves to `not_found`,
|
|
217
|
+
// which is the honest answer when nothing can be read at all.
|
|
218
|
+
this.deps.log?.("onlyne.judge.peers_unreadable", { error: message(error) });
|
|
219
|
+
}
|
|
220
|
+
return [...ids];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `meta.thread`: a `{owner,id}` record or a text reference. The record form carries the owner
|
|
225
|
+
* explicitly; the text form is resolved by the rule in `threadByAlias`.
|
|
226
|
+
*/
|
|
227
|
+
async thread(meta: Record<string, Json> | undefined): Promise<ThreadRef | undefined> {
|
|
228
|
+
if (meta === undefined) return undefined;
|
|
229
|
+
const value = meta.thread;
|
|
230
|
+
if (value === undefined) return undefined;
|
|
231
|
+
if (typeof value === "string") {
|
|
232
|
+
if (value.length === 0) throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
233
|
+
return this.threadByAlias(value);
|
|
234
|
+
}
|
|
235
|
+
const record = recordField(meta, "thread");
|
|
236
|
+
const owner = record === undefined ? undefined : stringField(record, "owner");
|
|
237
|
+
const id = record === undefined ? undefined : stringField(record, "id");
|
|
238
|
+
if (owner !== undefined && id !== undefined) return { owner, id };
|
|
239
|
+
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One textual thread reference, resolved by evidence this node actually holds:
|
|
244
|
+
*
|
|
245
|
+
* 1. a thread it hosts or was invited into, matched by id or title; a unique match is the
|
|
246
|
+
* answer and several are `ambiguous_target`;
|
|
247
|
+
* 2. otherwise `owner/id`, where the part before the last slash is an explicit owner and is
|
|
248
|
+
* accepted as written, because the owner is a claim the caller made;
|
|
249
|
+
* 3. otherwise `not_found`.
|
|
250
|
+
*
|
|
251
|
+
* Step 3 used to differ by entry point: a send read an unknown bare id as the addressee's
|
|
252
|
+
* thread and stored into a thread nobody had told that node about, while a read refused it.
|
|
253
|
+
* No local record named that owner, so the send was an invented fact, not a resolution
|
|
254
|
+
* (audit §2.7).
|
|
255
|
+
*/
|
|
256
|
+
async threadByAlias(alias: string): Promise<ThreadRef> {
|
|
257
|
+
if (alias.length === 0) throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
258
|
+
const matches = (await this.namedThreads()).filter(thread => thread.ref.id === alias || thread.title === alias);
|
|
259
|
+
if (matches.length === 1) return matches[0]!.ref;
|
|
260
|
+
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
|
|
261
|
+
// One slash grammar for every textual thread reference: the owner is the part before the
|
|
262
|
+
// LAST slash, which is safe for urn owners and for ids that never carry a slash.
|
|
263
|
+
const slash = alias.lastIndexOf("/");
|
|
264
|
+
if (slash > 0 && slash < alias.length - 1) {
|
|
265
|
+
return { owner: alias.slice(0, slash), id: alias.slice(slash + 1) };
|
|
266
|
+
}
|
|
267
|
+
throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Every thread this node can name by id or title: the ones it hosts, plus the ones it was
|
|
272
|
+
* invited into. A joined thread lives on its owner, so naming it here is a reference, never
|
|
273
|
+
* a claim that this node holds the thread record.
|
|
274
|
+
*/
|
|
275
|
+
async namedThreads(): Promise<Array<{ ref: ThreadRef; title: string | undefined }>> {
|
|
276
|
+
const [owned, joined] = await Promise.all([this.deps.listThreads(), this.deps.listJoinedThreads()]);
|
|
277
|
+
return [
|
|
278
|
+
...owned.map(thread => ({ ref: thread.thread_ref, title: thread.title as string | undefined })),
|
|
279
|
+
...joined.map(thread => ({ ref: thread.thread_ref, title: thread.title })),
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
|
|
284
|
+
if (meta === undefined) return undefined;
|
|
285
|
+
const value = meta.mentions;
|
|
286
|
+
if (value === undefined) return undefined;
|
|
287
|
+
if (!Array.isArray(value)) throw new OnlyneError("invalid_request", text("error.mentions_array"));
|
|
288
|
+
const resolved: string[] = [];
|
|
289
|
+
for (const item of value) {
|
|
290
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
291
|
+
throw new OnlyneError("invalid_request", text("error.mention_empty"));
|
|
292
|
+
}
|
|
293
|
+
if (item === "@all") {
|
|
294
|
+
resolved.push(item);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
resolved.push(await this.peerId(item.startsWith("@") ? item.slice(1) : item));
|
|
298
|
+
}
|
|
299
|
+
return resolved;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async reply(meta: Record<string, Json> | undefined): Promise<ReplyReference | undefined> {
|
|
303
|
+
if (meta === undefined) return undefined;
|
|
304
|
+
const value = meta.in_reply_to;
|
|
305
|
+
if (value === undefined) return undefined;
|
|
306
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
307
|
+
throw new OnlyneError("invalid_request", text("error.in_reply_to_object"));
|
|
308
|
+
}
|
|
309
|
+
const record = value as Record<string, Json>;
|
|
310
|
+
const sender = stringField(record, "sender");
|
|
311
|
+
const id = stringField(record, "id");
|
|
312
|
+
if (sender !== undefined && id !== undefined) {
|
|
313
|
+
// The authority is an identity, not the caller's spelling: `caerulea` and
|
|
314
|
+
// `urn:onlyne:agent:caerulea` name the same node, so both the ref and the authority
|
|
315
|
+
// carry the resolved id and every later comparison is like-for-like. Comparing a raw
|
|
316
|
+
// string against a resolved one produced two contradictory errors in a row
|
|
317
|
+
// (cyanea report 1, problem 1).
|
|
318
|
+
const authority = await this.peerId(sender).catch(() => sender);
|
|
319
|
+
// A reference the node can prove wrong is refused here, before the ref reaches the
|
|
320
|
+
// wire and dies in the peer's not_found: an id that names a different sender locally
|
|
321
|
+
// is a definite error, and the error names the message the id really belongs to.
|
|
322
|
+
const elsewhere = (await this.deps.listInbox()).find(row =>
|
|
323
|
+
row.attention.entry !== undefined && row.attention.entry.message.id === id
|
|
324
|
+
&& row.attention.entry.message.sender !== authority);
|
|
325
|
+
if (elsewhere !== undefined) {
|
|
326
|
+
const actual = elsewhere.attention.entry!.message.sender;
|
|
327
|
+
throw new OnlyneError("invalid_request", text("error.reply_reference_conflict", { named: sender, actual }));
|
|
328
|
+
}
|
|
329
|
+
return { ref: { sender: authority, id }, authority };
|
|
330
|
+
}
|
|
331
|
+
const threadAlias = stringField(record, "thread");
|
|
332
|
+
const seq = seqField(record, "seq");
|
|
333
|
+
if (threadAlias === undefined) throw new OnlyneError("invalid_request", text("error.in_reply_to_shape"));
|
|
334
|
+
if (seq === undefined) throw new OnlyneError("invalid_request", text("error.in_reply_to_seq"));
|
|
335
|
+
const thread = await this.threadByAlias(threadAlias);
|
|
336
|
+
const entry = await this.entryAt(thread, seq);
|
|
337
|
+
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: threadAlias, seq }));
|
|
338
|
+
return { ref: { sender: entry.message.sender, id: entry.message.id }, authority: entry.reply_authority, thread_ref: entry.thread_ref };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The message a read names, with the thread it was resolved in. `{sender,id}` carries no
|
|
343
|
+
* thread here; `{thread,seq}` does, and an explicit meta.thread that contradicts the
|
|
344
|
+
* reference is refused rather than silently winning.
|
|
345
|
+
*/
|
|
346
|
+
async message(meta: Record<string, Json>, thread?: ThreadRef): Promise<{ ref: MessageRef; thread?: ThreadRef } | undefined> {
|
|
347
|
+
const value = meta.message;
|
|
348
|
+
const record = value !== null && typeof value === "object" && !Array.isArray(value)
|
|
349
|
+
? (value as Record<string, Json>)
|
|
350
|
+
: undefined;
|
|
351
|
+
const sender = record === undefined ? undefined : stringField(record, "sender");
|
|
352
|
+
const id = record === undefined ? undefined : stringField(record, "id");
|
|
353
|
+
if (sender !== undefined && id !== undefined) {
|
|
354
|
+
return { ref: { sender, id }, ...(thread === undefined ? {} : { thread }) };
|
|
355
|
+
}
|
|
356
|
+
// meta.message takes the same two shapes as meta.in_reply_to, including its own thread
|
|
357
|
+
// reference: `{thread, seq}` is the form a reader copies out of another reference, and
|
|
358
|
+
// refusing it while accepting `{sender, id}` made the same fact two spellings (caerulea 4).
|
|
359
|
+
const alias = record === undefined ? undefined : stringField(record, "thread");
|
|
360
|
+
const embedded = alias === undefined ? undefined : await this.threadByAlias(alias);
|
|
361
|
+
if (thread !== undefined && embedded !== undefined && threadKey(thread) !== threadKey(embedded)) {
|
|
362
|
+
throw new OnlyneError(
|
|
363
|
+
"invalid_request",
|
|
364
|
+
text("error.message_thread_conflict", { thread: threadKey(thread), embedded: threadKey(embedded) }),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const scope = thread ?? embedded;
|
|
368
|
+
if (scope === undefined) return undefined;
|
|
369
|
+
const seq = (record === undefined ? undefined : seqField(record, "seq")) ?? seqField(meta, "seq");
|
|
370
|
+
if (seq === undefined) return undefined;
|
|
371
|
+
const entry = await this.entryAt(scope, seq);
|
|
372
|
+
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: scope.id, seq }));
|
|
373
|
+
return { ref: { sender: entry.message.sender, id: entry.message.id }, thread: scope };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async entryAt(thread: ThreadRef, seq: string): Promise<Entry | undefined> {
|
|
377
|
+
let cursor: string | undefined;
|
|
378
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
379
|
+
const result: Page<Entry> = await this.deps.listMessages(thread, cursor);
|
|
380
|
+
const found = result.items.find(entry => entry.seq === seq);
|
|
381
|
+
if (found !== undefined) return found;
|
|
382
|
+
cursor = result.next_cursor ?? undefined;
|
|
383
|
+
if (cursor === undefined) return undefined;
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
package/src/resources.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OnlyneError, trustPhase, type OnlyneNode, type ResourceReadQuery } from "@onlyne/lib";
|
|
2
|
-
import { hostText as t } from "
|
|
2
|
+
import { hostText as t } from "@onlyne/node-host/host-text";
|
|
3
3
|
|
|
4
4
|
export async function resourceOwners(node: OnlyneNode): Promise<string[]> {
|
|
5
5
|
const trusted = await node.trust.list(node.auth);
|