@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/README.md +2 -0
- package/package.json +1 -1
- package/src/client.ts +255 -59
- package/src/core.ts +2 -2
- package/src/discovery.ts +26 -18
- package/src/domains.ts +29 -26
- package/src/history.ts +21 -11
- package/src/http.ts +25 -14
- package/src/index.ts +18 -18
- package/src/keys.ts +81 -71
- package/src/memory.ts +1 -1
- package/src/messages.ts +63 -52
- package/src/node.ts +23 -16
- package/src/resolve.ts +39 -0
- package/src/resources.ts +3 -3
- package/src/threads.ts +98 -39
- package/src/transport.ts +18 -11
- package/src/types.ts +54 -9
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.
|
|
2
|
-
import { admissionView, canonical, decimal, fail, fingerprint, key, OnlyneError, page, requireValue, same, type Context } from './core.
|
|
3
|
-
import { canRead, getThread } from './threads.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
this
|
|
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
|
|
89
|
-
&& (message.meta?.relation !== 'followup' || permit.followup) && this
|
|
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
|
|
96
|
-
if (landing === undefined || landing.owner !== this
|
|
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
|
|
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
|
|
106
|
-
threadRef = { owner: this
|
|
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
|
|
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
|
|
114
|
-
: await getThread(tx, this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
174
|
-
guarantee: this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
264
|
-
const thread = await getThread(tx, this
|
|
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
|
|
290
|
-
await getThread(tx, this
|
|
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
|
|
311
|
-
await getThread(tx, this
|
|
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
|
|
325
|
-
return this
|
|
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
|
|
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
|
|
344
|
-
await getThread(tx, this
|
|
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
|
|
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
|
|
361
|
-
const thread = await getThread(tx, this
|
|
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
|
|
372
|
-
if (auth.actor !== this
|
|
373
|
-
return this
|
|
374
|
-
await getThread(tx, this
|
|
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
|
|
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.
|
|
2
|
-
import { createContext } from './core.
|
|
3
|
-
import { ThreadService } from './threads.
|
|
4
|
-
import { MessageService } from './messages.
|
|
5
|
-
import { createResourceService } from './resources.
|
|
6
|
-
import type { ResourceService } from './resources.
|
|
7
|
-
import { DomainService } from './domains.
|
|
8
|
-
import { DiscoveryService } from './discovery.
|
|
9
|
-
import { OnlyneClient } from './client.
|
|
10
|
-
import { KeyService, TrustService } from './keys.
|
|
11
|
-
import { FetchTransport } from './transport.
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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.
|
|
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
|
-
|
|
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.
|
|
5
|
-
import type { Context } from './core.
|
|
6
|
-
import { OnlyneError, decimal, fail, key, page, requireValue, same, utc } from './core.
|
|
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>;
|