@onlyne/lib 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/messages.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { Attention, Auth, DeliveryPlan, Entry, Event, Failure, Grant, Json, LocalAttention, Member, MessageQuery, MessageRef, Mutation, Outbound, PageOptions, Receipt, ReplyPermit, SendRequest, Thread, ThreadRef, Transaction } from './types.js';
2
- import { admissionView, canonical, decimal, fail, fingerprint, key, OnlyneError, page, requireValue, same, type Context } from './core.js';
3
- import { canRead, getThread } from './threads.js';
1
+ import type { Attention, Auth, DeliveryPlan, Entry, Event, Failure, Grant, Json, LocalAttention, Member, MessageQuery, MessageRef, Mutation, Outbound, PageOptions, Receipt, ReplyPermit, SendRequest, Thread, ThreadRef, Transaction } from './types.ts';
2
+ import { admissionView, canonical, decimal, fail, fingerprint, key, OnlyneError, page, requireValue, same, type Context } from './core.ts';
3
+ import { canRead, getThread } from './threads.ts';
4
4
 
5
5
  type Remembered = { digest: string; receipt?: Receipt; error?: Failure; retry_until: string };
6
6
  type Binding = { digest: string; identity: string };
@@ -18,14 +18,18 @@ const RECORDED_REFUSALS: Record<string, true> = {
18
18
  const compareOrdinal = (a: string, b: string): number => decimal(a) < decimal(b) ? -1 : decimal(a) > decimal(b) ? 1 : 0;
19
19
 
20
20
  export class MessageService {
21
- constructor(private readonly ctx: Context) {}
21
+ readonly #ctx: Context;
22
+
23
+ constructor(ctx: Context) {
24
+ this.#ctx = ctx;
25
+ }
22
26
 
23
27
  async admit(auth: Auth, request: SendRequest): Promise<Receipt> {
24
28
  const { message } = request;
25
29
  if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
26
30
  if (typeof message?.id !== 'string' || !message.id || typeof message.sender !== 'string' || typeof message.msg !== 'string' || typeof message.delivery_kind !== 'string') fail('invalid_request', 'A message needs id, sender, delivery_kind and msg.');
27
31
  if (message.sender !== auth.actor && auth.sender !== message.sender) fail('forbidden', 'The caller cannot represent this author.');
28
- if (request.target_endpoint !== this.ctx.node) fail('forbidden', 'The target endpoint is not served here.');
32
+ if (request.target_endpoint !== this.#ctx.node) fail('forbidden', 'The target endpoint is not served here.');
29
33
  if (!request.plan || !['none', 'async', 'await'].includes(request.plan.response) || !['existing', 'new'].includes(request.plan.execution)) fail('invalid_request', 'An executable delivery plan is required.');
30
34
  if (!request.idempotency_key) fail('invalid_request', 'An idempotency key is required.');
31
35
  if (message.meta?.relation && !message.meta.in_reply_to) fail('invalid_request', 'A relation requires an original message.');
@@ -37,14 +41,14 @@ export class MessageService {
37
41
  // Asked before the admission transaction opens: a host that answers from its own store
38
42
  // would otherwise wait on this transaction and deadlock it. The refusal it produces is
39
43
  // still recorded inside the transaction below.
40
- const runtime = this.ctx.options.plugins.runtime;
44
+ const runtime = this.#ctx.options.plugins.runtime;
41
45
  const supported = runtime === undefined || await runtime.supports(request.plan, message);
42
- const outcome = await this.ctx.storage.transaction(async (tx): Promise<Outcome> => {
46
+ const outcome = await this.#ctx.storage.transaction(async (tx): Promise<Outcome> => {
43
47
  const bound = await tx.get<Binding>(operationKey);
44
48
  if (bound && (bound.digest !== requestDigest || bound.identity !== identity)) fail('id_conflict', 'The idempotency key is bound to another request.');
45
49
  const old = await tx.get<Remembered>(identity);
46
50
  if (old) {
47
- this.ctx.options.plugins.runtime?.trace?.({ event: 'inbound.duplicate', sender: message.sender, message: message.id });
51
+ this.#ctx.options.plugins.runtime?.trace?.({ event: 'inbound.duplicate', sender: message.sender, message: message.id });
48
52
  // A different body never replaces the identity already recorded, accepted or refused.
49
53
  if (old.digest !== digest) return { error: new OnlyneError('id_conflict', 'This message identity has different content.').error };
50
54
  if (old.error) return { error: old.error };
@@ -54,9 +58,16 @@ export class MessageService {
54
58
  }
55
59
  try {
56
60
  if (!supported) {
57
- return await this.refuse(tx, identity, digest, request, new OnlyneError('unsupported_delivery', 'The runtime cannot fulfill this delivery plan.'));
61
+ // The plan is the only thing the sender can change, so the refusal names it: a host
62
+ // that cannot hold an await or open a new context says so, instead of leaving the
63
+ // reader to guess which half of the plan it refused.
64
+ return await this.refuse(tx, identity, digest, request, new OnlyneError(
65
+ 'unsupported_delivery',
66
+ `This host cannot deliver a ${request.plan.response} response with ${request.plan.execution} execution; nothing was stored.`,
67
+ { plan: { response: request.plan.response, execution: request.plan.execution } },
68
+ ));
58
69
  }
59
- this.ctx.deadline(request.retry_until);
70
+ this.#ctx.deadline(request.retry_until);
60
71
  return await this.append(tx, auth, request, digest, requestDigest, identity, operationKey);
61
72
  } catch (error) {
62
73
  if (error instanceof OnlyneError && RECORDED_REFUSALS[error.error.code] === true) return this.refuse(tx, identity, digest, request, error);
@@ -64,8 +75,8 @@ export class MessageService {
64
75
  }
65
76
  });
66
77
  if ('error' in outcome) throw new OnlyneError(outcome.error.code, outcome.error.message, outcome.error.details);
67
- this.ctx.options.plugins.runtime?.notify?.({ event: 'inbound', thread: outcome.receipt.thread_ref, seq: outcome.receipt.seq, sender: message.sender });
68
- this.ctx.options.plugins.runtime?.notify?.({ event: 'work-pending', pending: true });
78
+ this.#ctx.options.plugins.runtime?.notify?.({ event: 'inbound', thread: outcome.receipt.thread_ref, seq: outcome.receipt.seq, sender: message.sender });
79
+ this.#ctx.options.plugins.runtime?.notify?.({ event: 'work-pending', pending: true });
69
80
  return requireValue(outcome.receipt);
70
81
  }
71
82
 
@@ -85,40 +96,40 @@ export class MessageService {
85
96
  let permit: ReplyPermit | undefined;
86
97
  if (original !== undefined) {
87
98
  permit = await tx.get<ReplyPermit>(key('permit', original.sender, original.id, message.sender));
88
- if (threadRef === undefined && permit && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.ctx.now()
89
- && (message.meta?.relation !== 'followup' || permit.followup) && this.ctx.node === permit.landing.owner) threadRef = permit.landing;
99
+ if (threadRef === undefined && permit && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.#ctx.now()
100
+ && (message.meta?.relation !== 'followup' || permit.followup) && this.#ctx.node === permit.landing.owner) threadRef = permit.landing;
90
101
  if (threadRef === undefined) threadRef = await this.chainLanding(tx, original, message.sender);
91
102
  }
92
103
  let firstContact = false;
93
104
  if (threadRef === undefined) {
94
105
  // The default landing is the owner's own slot, resolved once inside this transaction.
95
- const landing = await tx.get<ThreadRef>(key('default', this.ctx.node));
96
- if (landing === undefined || landing.owner !== this.ctx.node || typeof landing.id !== 'string' || landing.id.length === 0) {
106
+ const landing = await tx.get<ThreadRef>(key('default', this.#ctx.node));
107
+ if (landing === undefined || landing.owner !== this.#ctx.node || typeof landing.id !== 'string' || landing.id.length === 0) {
97
108
  // Finding a landing grants nothing (RFC 0002 §3): only the owner's inbound policy
98
109
  // may open a thread and admit this sender into it.
99
- const policy = this.ctx.options.plugins.inboundThread;
110
+ const policy = this.#ctx.options.plugins.inboundThread;
100
111
  if (policy === undefined || !await policy(auth.actor, admissionView(tx))) {
101
112
  return this.refuse(tx, identity, digest, request, new OnlyneError('not_found', 'No default thread is configured.'));
102
113
  }
103
114
  firstContact = true;
104
115
  let id = crypto.randomUUID();
105
- while (await tx.get<Thread>(key('thread', this.ctx.node, id))) id = crypto.randomUUID();
106
- threadRef = { owner: this.ctx.node, id };
116
+ while (await tx.get<Thread>(key('thread', this.#ctx.node, id))) id = crypto.randomUUID();
117
+ threadRef = { owner: this.#ctx.node, id };
107
118
  } else {
108
119
  threadRef = { owner: landing.owner, id: landing.id };
109
120
  }
110
121
  }
111
- this.ctx.owner(threadRef.owner);
122
+ this.#ctx.owner(threadRef.owner);
112
123
  const thread: Thread = firstContact
113
- ? { thread_ref: threadRef, title: message.msg.replace(/\s+/g, ' ').trim().slice(0, 100) || message.delivery_kind, state: 'open', next_seq: '1', event_seq: '0', created_at: new Date(this.ctx.now()).toISOString(), closed_at: null }
114
- : await getThread(tx, this.ctx, threadRef);
124
+ ? { thread_ref: threadRef, title: message.msg.replace(/\s+/g, ' ').trim().slice(0, 100) || message.delivery_kind, state: 'open', next_seq: '1', event_seq: '0', created_at: new Date(this.#ctx.now()).toISOString(), closed_at: null }
125
+ : await getThread(tx, this.#ctx, threadRef);
115
126
  const member = firstContact ? undefined : await tx.get<Member>(key('member', threadRef.owner, threadRef.id, message.sender));
116
127
  const ordinary = firstContact || message.sender === threadRef.owner || (member?.state === 'active' && member.access.includes('post'));
117
- const allowedPermit = permit !== undefined && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.ctx.now()
128
+ const allowedPermit = permit !== undefined && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.#ctx.now()
118
129
  && same(permit.landing, threadRef) && (message.meta?.relation !== 'followup' || permit.followup);
119
130
  if (!ordinary && !allowedPermit) return this.refuse(tx, identity, digest, request, new OnlyneError('not_a_member', 'The sender is not an active post member.'));
120
131
  if (thread.state === 'closed') return this.refuse(tx, identity, digest, request, new OnlyneError('thread_closed', 'The thread is closed.'));
121
- const admissionPolicy = this.ctx.options.plugins.mayAdmit;
132
+ const admissionPolicy = this.#ctx.options.plugins.mayAdmit;
122
133
  if (admissionPolicy && !await admissionPolicy(auth.actor, threadRef, admissionView(tx))) {
123
134
  return this.refuse(tx, identity, digest, request, new OnlyneError('forbidden', 'The admission policy refused the request.'));
124
135
  }
@@ -139,7 +150,7 @@ export class MessageService {
139
150
  if (alias === threadRef.owner) { recipients.add(threadRef.owner); continue; }
140
151
  if (members.some(m => m.peer === alias)) { recipients.add(alias); continue; }
141
152
  // A display alias is not a node id; the integration layer owns the mapping.
142
- const resolved = await this.ctx.options.plugins.mention?.(alias, threadRef);
153
+ const resolved = await this.#ctx.options.plugins.mention?.(alias, threadRef);
143
154
  if (resolved === undefined || (resolved !== threadRef.owner && !members.some(m => m.peer === resolved))) {
144
155
  fail('invalid_request', 'A mention must resolve to an active member identity.');
145
156
  }
@@ -153,7 +164,7 @@ export class MessageService {
153
164
  if (firstContact) {
154
165
  // Written with the entry and the receipt in this one transaction: the thread, its
155
166
  // sender membership and the grant notice either all commit or none of them do.
156
- const revision = this.ctx.id();
167
+ const revision = this.#ctx.id();
157
168
  const membership: Member = { peer: message.sender, state: 'active', access: ['post', 'read'], read_scope: {}, revision };
158
169
  const notice: Grant = { id: revision, recipient: message.sender, thread_ref: threadRef, revision, acked: false };
159
170
  const eventId = (decimal(thread.event_seq) + 1n).toString();
@@ -167,11 +178,11 @@ export class MessageService {
167
178
  thread.event_seq = (decimal(thread.event_seq) + 1n).toString();
168
179
  const entry: Entry = {
169
180
  thread_ref: threadRef, seq, message: structuredClone(message), authenticated_author: message.sender,
170
- reply_authority: auth.reply_authority ?? auth.actor, at: new Date(this.ctx.now()).toISOString(),
181
+ reply_authority: auth.reply_authority ?? auth.actor, at: new Date(this.#ctx.now()).toISOString(),
171
182
  };
172
183
  const receipt: Receipt = {
173
- message_ref: { sender: message.sender, id: message.id }, receipt_id: this.ctx.id(),
174
- guarantee: this.ctx.storage.guarantee, thread_ref: threadRef, seq, plan: structuredClone(request.plan),
184
+ message_ref: { sender: message.sender, id: message.id }, receipt_id: this.#ctx.id(),
185
+ guarantee: this.#ctx.storage.guarantee, thread_ref: threadRef, seq, plan: structuredClone(request.plan),
175
186
  thread_created: firstContact, previous_seq: (decimal(seq) - 1n).toString(),
176
187
  // The answer a message gives is part of what the owner admitted: the sender can only
177
188
  // prove "I replied to that one" from the receipt, and the reference here is the pair
@@ -215,7 +226,7 @@ export class MessageService {
215
226
  const entry = await tx.get<Entry>(key('entry-ref', ref.sender, ref.id));
216
227
  if (entry !== undefined) {
217
228
  const landing = entry.thread_ref;
218
- this.ctx.owner(landing.owner);
229
+ this.#ctx.owner(landing.owner);
219
230
  const thread = await tx.get<Thread>(key('thread', landing.owner, landing.id));
220
231
  if (thread === undefined) fail('not_found', 'The original reply landing is no longer available.');
221
232
  if (thread.state === 'closed') fail('thread_closed', 'The original reply landing is closed; an explicitly authorized alternative is required.');
@@ -223,7 +234,7 @@ export class MessageService {
223
234
  }
224
235
  const sent: Outbound | undefined = await tx.get<Outbound>(key('outbound', ref.sender, ref.id));
225
236
  if (sent === undefined) fail('not_found', 'The original message history is unavailable; no alternative reply landing was authorized.');
226
- if (ref.sender !== this.ctx.node || sent.expected_author !== sender) fail('forbidden', 'This sender is not the expected reply author.');
237
+ if (ref.sender !== this.#ctx.node || sent.expected_author !== sender) fail('forbidden', 'This sender is not the expected reply author.');
227
238
  const parent: MessageRef | undefined = sent.request.message.meta?.in_reply_to;
228
239
  // The first reply to an outbound root has no local incoming thread yet.
229
240
  if (parent === undefined) return undefined;
@@ -242,7 +253,7 @@ export class MessageService {
242
253
  private async writeAttention(tx: Transaction, attention: Attention, plan: DeliveryPlan) {
243
254
  const attested: Attention = { ...attention, plan };
244
255
  await tx.put(key('attention', attested.thread_ref.owner, attested.thread_ref.id, attested.obligation_id), attested);
245
- if (attested.recipient !== this.ctx.node) return;
256
+ if (attested.recipient !== this.#ctx.node) return;
246
257
  const slot = key('inbox', attested.obligation_id);
247
258
  if (await tx.get(slot) !== undefined) return;
248
259
  await tx.put<LocalAttention>(slot, {
@@ -250,7 +261,7 @@ export class MessageService {
250
261
  ...(plan.context === undefined ? {} : { context: plan.context }),
251
262
  injected: false,
252
263
  });
253
- this.ctx.options.plugins.runtime?.trace?.({
264
+ this.#ctx.options.plugins.runtime?.trace?.({
254
265
  event: 'inbound.received', obligation: attested.obligation_id, thread: attested.thread_ref,
255
266
  seq: attested.seq, sender: attested.message_ref.sender, valid: true, injected: false,
256
267
  ...(plan.context === undefined ? {} : { context: plan.context }),
@@ -260,8 +271,8 @@ export class MessageService {
260
271
  }
261
272
 
262
273
  async list(auth: Auth, ref: ThreadRef, query: MessageQuery = {}) {
263
- return this.ctx.storage.transaction(async tx => {
264
- const thread = await getThread(tx, this.ctx, ref);
274
+ return this.#ctx.storage.transaction(async tx => {
275
+ const thread = await getThread(tx, this.#ctx, ref);
265
276
  if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
266
277
  const entries: Entry[] = [];
267
278
  for (const { value: entry } of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) {
@@ -286,8 +297,8 @@ export class MessageService {
286
297
  * is stored back and before it is returned, so a revoked reader never sees content.
287
298
  */
288
299
  async attention(auth: Auth, ref: ThreadRef, options: PageOptions = {}) {
289
- return this.ctx.storage.transaction(async tx => {
290
- await getThread(tx, this.ctx, ref);
300
+ return this.#ctx.storage.transaction(async tx => {
301
+ await getThread(tx, this.#ctx, ref);
291
302
  const allowed = await canRead(tx, auth.actor, ref);
292
303
  const rows = (await tx.scan<Attention>(key('attention', ref.owner, ref.id) + '/')).map(r => r.value).filter(a => a.recipient === auth.actor && !a.acked);
293
304
  const visible: Attention[] = [];
@@ -307,8 +318,8 @@ export class MessageService {
307
318
  }
308
319
 
309
320
  async ack(auth: Auth, ref: ThreadRef, id: string, mutation: Mutation) {
310
- return this.ctx.mutate(auth, `attention-ack/${canonical(ref)}/${id}`, mutation, {}, async tx => {
311
- await getThread(tx, this.ctx, ref);
321
+ return this.#ctx.mutate(auth, `attention-ack/${canonical(ref)}/${id}`, mutation, {}, async tx => {
322
+ await getThread(tx, this.#ctx, ref);
312
323
  const slot = key('attention', ref.owner, ref.id, id);
313
324
  const attention = requireValue(await tx.get<Attention>(slot));
314
325
  if (attention.recipient !== auth.actor) fail('not_found', 'This attention obligation is not addressed to the caller.');
@@ -321,8 +332,8 @@ export class MessageService {
321
332
  }
322
333
 
323
334
  async inbox(auth: Auth, options: PageOptions = {}) {
324
- if (auth.actor !== this.ctx.node) fail('forbidden', 'The local inbox belongs to this node.');
325
- return this.ctx.storage.transaction(async tx => {
335
+ if (auth.actor !== this.#ctx.node) fail('forbidden', 'The local inbox belongs to this node.');
336
+ return this.#ctx.storage.transaction(async tx => {
326
337
  const rows = (await tx.scan<LocalAttention>('inbox/')).map(r => r.value);
327
338
  rows.sort((a, b) => a.attention.obligation_id.localeCompare(b.attention.obligation_id));
328
339
  const indexed = rows.map((value, index) => ({ value, position: String(index + 1) }));
@@ -332,7 +343,7 @@ export class MessageService {
332
343
  }
333
344
 
334
345
  async getByRef(auth: Auth, ref: MessageRef) {
335
- return this.ctx.storage.transaction(async tx => {
346
+ return this.#ctx.storage.transaction(async tx => {
336
347
  const entry = requireValue(await tx.get<Entry>(key('entry-ref', ref.sender, ref.id)));
337
348
  if (!await canRead(tx, auth.actor, entry.thread_ref, entry)) fail('not_found', 'This message is not readable.');
338
349
  return entry;
@@ -340,15 +351,15 @@ export class MessageService {
340
351
  }
341
352
 
342
353
  async events(auth: Auth, ref: ThreadRef, options: PageOptions = {}) {
343
- return this.ctx.storage.transaction(async tx => {
344
- await getThread(tx, this.ctx, ref);
354
+ return this.#ctx.storage.transaction(async tx => {
355
+ await getThread(tx, this.#ctx, ref);
345
356
  if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
346
357
  const rows: Event[] = [];
347
358
  for (const { value: event } of await tx.scan<Event>(key('event', ref.owner, ref.id) + '/')) {
348
359
  if (event.message_ref) {
349
360
  const entry = await tx.get<Entry>(key('entry-ref', event.message_ref.sender, event.message_ref.id));
350
361
  if (!entry || !await canRead(tx, auth.actor, ref, entry)) continue;
351
- } else if (event.type === 'membership' && auth.actor !== this.ctx.node) continue;
362
+ } else if (event.type === 'membership' && auth.actor !== this.#ctx.node) continue;
352
363
  rows.push(event);
353
364
  }
354
365
  rows.sort((a, b) => compareOrdinal(a.id, b.id));
@@ -357,8 +368,8 @@ export class MessageService {
357
368
  }
358
369
 
359
370
  async snapshot(auth: Auth, ref: ThreadRef) {
360
- return this.ctx.storage.transaction(async tx => {
361
- const thread = await getThread(tx, this.ctx, ref);
371
+ return this.#ctx.storage.transaction(async tx => {
372
+ const thread = await getThread(tx, this.#ctx, ref);
362
373
  if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
363
374
  const entries: Entry[] = [];
364
375
  for (const row of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) if (await canRead(tx, auth.actor, ref, row.value)) entries.push(row.value);
@@ -368,15 +379,15 @@ export class MessageService {
368
379
  }
369
380
 
370
381
  async expire(auth: Auth, ref: ThreadRef, beforeSeq: string) {
371
- this.ctx.owner(ref.owner);
372
- if (auth.actor !== this.ctx.node) fail('forbidden', 'Only the owner may expire history.');
373
- return this.ctx.storage.transaction(async tx => {
374
- await getThread(tx, this.ctx, ref);
382
+ this.#ctx.owner(ref.owner);
383
+ if (auth.actor !== this.#ctx.node) fail('forbidden', 'Only the owner may expire history.');
384
+ return this.#ctx.storage.transaction(async tx => {
385
+ await getThread(tx, this.#ctx, ref);
375
386
  for (const row of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) {
376
387
  if (decimal(row.value.seq) >= decimal(beforeSeq)) continue;
377
388
  const recordKey = key('message', row.value.message.sender, row.value.message.id);
378
389
  const record = requireValue(await tx.get<Remembered>(recordKey));
379
- if (Date.parse(record.retry_until) > this.ctx.now()) fail('conflict', 'The replay window is still open.');
390
+ if (Date.parse(record.retry_until) > this.#ctx.now()) fail('conflict', 'The replay window is still open.');
380
391
  // The tombstone keeps the digest and the window, never the body.
381
392
  await tx.put(recordKey, { digest: record.digest, retry_until: record.retry_until });
382
393
  await tx.delete(row.key);
package/src/node.ts CHANGED
@@ -1,16 +1,17 @@
1
- import type { Auth, NodeOptions } from './types.js';
2
- import { createContext } from './core.js';
3
- import { ThreadService } from './threads.js';
4
- import { MessageService } from './messages.js';
5
- import { createResourceService } from './resources.js';
6
- import type { ResourceService } from './resources.js';
7
- import { DomainService } from './domains.js';
8
- import { DiscoveryService } from './discovery.js';
9
- import { OnlyneClient } from './client.js';
10
- import { KeyService, TrustService } from './keys.js';
11
- import { FetchTransport } from './transport.js';
12
- import { createHttpHandler } from './http.js';
13
- import { HistoryService } from './history.js';
1
+ import type { Auth, NodeOptions } from './types.ts';
2
+ import { createContext } from './core.ts';
3
+ import { ThreadService } from './threads.ts';
4
+ import { MessageService } from './messages.ts';
5
+ import { createResourceService } from './resources.ts';
6
+ import type { ResourceService } from './resources.ts';
7
+ import { DomainService } from './domains.ts';
8
+ import { DiscoveryService } from './discovery.ts';
9
+ import { OnlyneClient } from './client.ts';
10
+ import { KeyService, TrustService } from './keys.ts';
11
+ import { FetchTransport } from './transport.ts';
12
+ import { composeResolver } from './resolve.ts';
13
+ import { createHttpHandler } from './http.ts';
14
+ import { HistoryService } from './history.ts';
14
15
 
15
16
  export class OnlyneNode {
16
17
  readonly threads: ThreadService;
@@ -23,7 +24,7 @@ export class OnlyneNode {
23
24
  readonly trust: TrustService;
24
25
  readonly client: OnlyneClient;
25
26
  /** The transport this node signs with; a host that talks to peers itself must reuse it. */
26
- readonly transport: import('./types.js').Transport;
27
+ readonly transport: import('./types.ts').Transport;
27
28
  readonly fetch: (request: Request) => Promise<Response>;
28
29
  readonly auth: Auth;
29
30
 
@@ -35,8 +36,15 @@ export class OnlyneNode {
35
36
  this.history = new HistoryService(ctx);
36
37
  this.keys = new KeyService(ctx);
37
38
  this.trust = new TrustService(ctx, this.keys);
39
+ this.discovery = new DiscoveryService(ctx);
38
40
  const transport = options.plugins.transport ?? new FetchTransport(
39
- options.plugins.resolve,
41
+ // The composed resolver is the one address authority (resolve.ts): discovery first,
42
+ // the trust row as the fallback that writes a moved peer's address back.
43
+ composeResolver({
44
+ resolve: options.plugins.resolve,
45
+ trust: this.trust,
46
+ observe: (base_url, source, expectedNode) => this.discovery.observe(base_url, source, expectedNode),
47
+ }),
40
48
  fetch,
41
49
  options.plugins.keys === undefined ? undefined : { trust: this.trust },
42
50
  );
@@ -44,7 +52,6 @@ export class OnlyneNode {
44
52
  // transport instead of reaching the network on its own.
45
53
  this.resources = createResourceService(ctx, transport);
46
54
  this.domains = new DomainService(ctx);
47
- this.discovery = new DiscoveryService(ctx);
48
55
  this.transport = transport;
49
56
  this.client = new OnlyneClient(ctx, transport, this.messages, this.threads, this.resources, this.domains, this.trust);
50
57
  this.trust.bindLoop(this.client);
package/src/resolve.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { OnlyneError } from './core.ts';
2
+ import type { Failure, NodeId, Target, TrustEntry } from './types.ts';
3
+
4
+ /**
5
+ * The one place an address is composed for the wire (RFC 0011 §4, RFC 0018 §5.1).
6
+ * The host resolver — usually the discovery catalog — answers first. When it reports
7
+ * `not_found` (never observed) or `unavailable` (recorded but no address answers), a
8
+ * trusted row's `base_url` is the authoritative fallback: probing it succeeds writes the
9
+ * address back into the discovery catalog, so a peer that moved stays reachable and the
10
+ * new address becomes visible to the model. A trusted row whose address also fails is
11
+ * `unavailable` — never degraded to `not_found`, which names an unknown identity.
12
+ */
13
+ export function composeResolver(deps: {
14
+ resolve: (node: NodeId) => Promise<Target>;
15
+ trust?: { entry(node: NodeId): Promise<TrustEntry | undefined> };
16
+ observe?: (base_url: string, source: string, expectedNode?: NodeId) => Promise<unknown>;
17
+ }): (node: NodeId) => Promise<Target> {
18
+ return async node => {
19
+ try {
20
+ return await deps.resolve(node);
21
+ } catch (error) {
22
+ const failure: Failure | undefined = error instanceof OnlyneError
23
+ ? error.error
24
+ : (error as { error?: Failure } | null)?.error;
25
+ if (failure?.code !== 'not_found' && failure?.code !== 'unavailable') throw error;
26
+ const entry = await deps.trust?.entry(node);
27
+ if (entry === undefined || entry.state !== 'trusted' || entry.base_url.length === 0) throw error;
28
+ if (deps.observe !== undefined) {
29
+ try {
30
+ await deps.observe(entry.base_url, 'trust-row', node);
31
+ } catch {
32
+ throw new OnlyneError('unavailable',
33
+ `${node} holds a trusted row whose address is ${entry.base_url}, but no recorded address answered and that address did not verify either.`);
34
+ }
35
+ }
36
+ return { node_id: node, base_url: entry.base_url };
37
+ }
38
+ };
39
+ }
package/src/resources.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type {
2
2
  AckReceipt, Auth, Failure, HandoverProbe, Json, LogQuery, Mutation, OperationRecord, OperationRequest,
3
3
  OperationResult, Page, PageOptions, ProbeAnswer, Resource, ResourceEvent, ResourceRef, Transaction, Transport,
4
- } from './types.js';
5
- import type { Context } from './core.js';
6
- import { OnlyneError, decimal, fail, key, page, requireValue, same, utc } from './core.js';
4
+ } from './types.ts';
5
+ import type { Context } from './core.ts';
6
+ import { OnlyneError, decimal, fail, key, page, requireValue, same, utc } from './core.ts';
7
7
 
8
8
  export interface ResourceService {
9
9
  register(auth: Auth, resource: Resource): Promise<Resource>;