@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/threads.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { admissionView, fail, key, page, requireValue, utc } from './core.
|
|
2
|
-
import type { Context } from './core.
|
|
3
|
-
import type { Auth, Entry, Event, Grant, Json, Member, MessageRef, Mutation, Page, PageOptions, ReadScope, ReplyPermit, Thread, ThreadRef, Transaction } from './types.
|
|
1
|
+
import { admissionView, decimal, fail, key, page, requireValue, utc } from './core.ts';
|
|
2
|
+
import type { Context } from './core.ts';
|
|
3
|
+
import type { Attention, Auth, Entry, Event, Grant, JoinedThread, Json, LocalAttention, Member, MessageRef, Mutation, Outbound, Page, PageOptions, ReadScope, ReplyPermit, Thread, ThreadDescription, ThreadRef, Transaction } from './types.ts';
|
|
4
4
|
|
|
5
5
|
/** One reserved default-thread id per owner: omitted targets land here, and the id is never reused. */
|
|
6
6
|
export const DEFAULT_THREAD = 'thr-default';
|
|
@@ -113,18 +113,22 @@ interface Known {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export class ThreadService {
|
|
116
|
-
|
|
116
|
+
readonly #ctx: Context;
|
|
117
|
+
|
|
118
|
+
constructor(ctx: Context) {
|
|
119
|
+
this.#ctx = ctx;
|
|
120
|
+
}
|
|
117
121
|
|
|
118
122
|
async create(auth: Auth, mutation: Mutation & { title: string; id?: string; default?: boolean }): Promise<Thread> {
|
|
119
123
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
120
124
|
const owner = auth.actor;
|
|
121
|
-
this
|
|
125
|
+
this.#ctx.owner(owner);
|
|
122
126
|
if (typeof mutation.title !== 'string' || mutation.title.length === 0) fail('invalid_request', 'A thread needs a title.');
|
|
123
127
|
if (mutation.id !== undefined && (typeof mutation.id !== 'string' || mutation.id.length === 0)) fail('invalid_request', 'Thread id must be a non-empty string.');
|
|
124
128
|
const wantsDefault = mutation.default === true || mutation.id === DEFAULT_THREAD;
|
|
125
129
|
const body = { title: mutation.title, id: mutation.id ?? null, default: wantsDefault };
|
|
126
|
-
return this
|
|
127
|
-
const at = new Date(this
|
|
130
|
+
return this.#ctx.mutate(auth, 'thread.create', mutation, body, async tx => {
|
|
131
|
+
const at = new Date(this.#ctx.now()).toISOString();
|
|
128
132
|
const existingDefault = await tx.get<Thread>(key('thread', owner, DEFAULT_THREAD));
|
|
129
133
|
if (wantsDefault) {
|
|
130
134
|
if (existingDefault) {
|
|
@@ -158,29 +162,67 @@ export class ThreadService {
|
|
|
158
162
|
async close(auth: Auth, ref: ThreadRef, mutation: Mutation): Promise<Thread> {
|
|
159
163
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
160
164
|
requireThreadRef(ref);
|
|
161
|
-
this
|
|
165
|
+
this.#ctx.owner(ref.owner);
|
|
162
166
|
if (auth.actor !== ref.owner) fail('forbidden', 'Only the owner may close a thread.');
|
|
163
167
|
const body = { ref };
|
|
164
|
-
return this
|
|
165
|
-
const thread = await loadThread(tx, this
|
|
168
|
+
return this.#ctx.mutate(auth, `thread.close/${ref.owner}/${ref.id}`, mutation, body, async tx => {
|
|
169
|
+
const thread = await loadThread(tx, this.#ctx, ref);
|
|
166
170
|
if (thread.state === 'closed') fail('thread_closed', 'The thread is already closed.');
|
|
167
|
-
return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this
|
|
171
|
+
return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this.#ctx.now()).toISOString() }, 'thread_closed', {});
|
|
168
172
|
});
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
async list(auth: Auth, options: PageOptions = {}): Promise<Page<Thread>> {
|
|
172
176
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
173
|
-
this
|
|
174
|
-
return this
|
|
177
|
+
this.#ctx.owner(auth.actor);
|
|
178
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
175
179
|
const threads = sortedBy((await tx.scan<Thread>(key('thread', auth.actor) + '/')).map(row => row.value), thread => thread.thread_ref.id);
|
|
176
180
|
return page(threads, options, { kind: 'threads', owner: auth.actor }, thread => thread.thread_ref.id);
|
|
177
181
|
});
|
|
178
182
|
}
|
|
179
183
|
|
|
180
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Participant view: the threads this node was invited into and does not own, derived only
|
|
186
|
+
* from the records it already holds — the known-owner rows and grant notices `acceptGrant`
|
|
187
|
+
* wrote, the attention and inbox rows that name a thread, and the receipts of messages it
|
|
188
|
+
* sent there. The title, the state and the member table belong to the owner, so they are
|
|
189
|
+
* absent here; a caller that can reach the owner fills them in. This listing never invents
|
|
190
|
+
* a member it could not read.
|
|
191
|
+
*/
|
|
192
|
+
async joined(auth: Auth, options: PageOptions = {}): Promise<Page<JoinedThread>> {
|
|
181
193
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
if (auth.actor !== this.#ctx.node) fail('forbidden', 'The joined-thread view belongs to this node.');
|
|
195
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
196
|
+
const rows = new Map<string, { ref: ThreadRef; seq: string; messages: Set<string> }>();
|
|
197
|
+
const hold = (ref: ThreadRef, seq?: string, message?: MessageRef): void => {
|
|
198
|
+
// A thread this node owns is already in `list()`; this view is the other side of it.
|
|
199
|
+
if (ref.owner === this.#ctx.node) return;
|
|
200
|
+
const slot = key(ref.owner, ref.id);
|
|
201
|
+
const held = rows.get(slot) ?? { ref: { owner: ref.owner, id: ref.id }, seq: '0', messages: new Set<string>() };
|
|
202
|
+
rows.set(slot, held);
|
|
203
|
+
if (seq !== undefined && decimal(seq) > decimal(held.seq)) held.seq = seq;
|
|
204
|
+
if (message !== undefined) held.messages.add(key(message.sender, message.id));
|
|
205
|
+
};
|
|
206
|
+
for (const row of await tx.scan<Known>(key('known') + '/')) hold(row.value.thread_ref);
|
|
207
|
+
for (const row of await tx.scan<Grant>(key('grant') + '/')) hold(row.value.thread_ref);
|
|
208
|
+
for (const row of await tx.scan<Attention>(key('attention') + '/')) hold(row.value.thread_ref, row.value.seq, row.value.message_ref);
|
|
209
|
+
for (const row of await tx.scan<LocalAttention>(key('inbox') + '/')) hold(row.value.attention.thread_ref, row.value.attention.seq, row.value.attention.message_ref);
|
|
210
|
+
for (const row of await tx.scan<Outbound>(key('outbound') + '/')) {
|
|
211
|
+
const receipt = row.value.receipt;
|
|
212
|
+
if (receipt !== undefined) hold(receipt.thread_ref, receipt.seq, receipt.message_ref);
|
|
213
|
+
}
|
|
214
|
+
const items = sortedBy([...rows.values()], row => key(row.ref.owner, row.ref.id))
|
|
215
|
+
.map((row): JoinedThread => ({
|
|
216
|
+
thread_ref: row.ref, last_seq: row.seq, message_count: row.messages.size, read_only: true,
|
|
217
|
+
}));
|
|
218
|
+
return page(items, options, { kind: 'joined', owner: auth.actor }, item => key(item.thread_ref.owner, item.thread_ref.id));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async describe(auth: Auth, ref: ThreadRef): Promise<ThreadDescription> {
|
|
223
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
224
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
225
|
+
const thread = await loadThread(tx, this.#ctx, ref);
|
|
184
226
|
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
|
|
185
227
|
const members = (await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value);
|
|
186
228
|
return { thread, members };
|
|
@@ -198,19 +240,19 @@ export class ThreadService {
|
|
|
198
240
|
fail('forbidden', 'A peer may only join or withdraw itself.');
|
|
199
241
|
}
|
|
200
242
|
const body = { ref, peer, patch };
|
|
201
|
-
return this
|
|
202
|
-
const thread = await loadThread(tx, this
|
|
243
|
+
return this.#ctx.mutate(auth, `thread.member/${ref.owner}/${ref.id}/${peer}`, mutation, body, async tx => {
|
|
244
|
+
const thread = await loadThread(tx, this.#ctx, ref);
|
|
203
245
|
const current = await tx.get<Member>(key('member', ref.owner, ref.id, peer));
|
|
204
246
|
let next: Member;
|
|
205
247
|
if (!isOwner) {
|
|
206
248
|
if (current === undefined) {
|
|
207
249
|
if (patch.state === 'revoked') fail('not_found', 'No membership record to withdraw.');
|
|
208
250
|
if (thread.state === 'closed') fail('thread_closed', 'A closed thread does not accept joins.');
|
|
209
|
-
const admitted = (await this
|
|
210
|
-
next = { peer, state: admitted ? 'active' : 'requested', access: [...DEFAULT_ACCESS], read_scope: {}, revision: this
|
|
251
|
+
const admitted = (await this.#ctx.options.plugins.mayAdmit?.(auth.actor, ref, admissionView(tx))) === true;
|
|
252
|
+
next = { peer, state: admitted ? 'active' : 'requested', access: [...DEFAULT_ACCESS], read_scope: {}, revision: this.#ctx.id() };
|
|
211
253
|
} else if (patch.state === 'revoked') {
|
|
212
254
|
if (current.state !== 'active' && current.state !== 'requested') fail('conflict', 'This membership is already stopped.');
|
|
213
|
-
next = { ...current, state: 'revoked', access: [], revision: this
|
|
255
|
+
next = { ...current, state: 'revoked', access: [], revision: this.#ctx.id() };
|
|
214
256
|
} else {
|
|
215
257
|
fail('conflict', 'This peer already holds a membership record; only the owner changes it.');
|
|
216
258
|
}
|
|
@@ -219,7 +261,7 @@ export class ThreadService {
|
|
|
219
261
|
if (state !== 'requested' && state !== 'active' && state !== 'revoked' && state !== 'denied') fail('invalid_request', 'Unknown member state.');
|
|
220
262
|
const stopping = state === 'revoked' || state === 'denied';
|
|
221
263
|
const access = stopping ? [] : patch.access === undefined ? (current?.state === 'active' ? current.access : [...DEFAULT_ACCESS]) : requireAccess(patch.access);
|
|
222
|
-
next = { peer, state, access: [...access], read_scope: patch.read_scope === undefined ? (current?.read_scope ?? {}) : requireScope(patch.read_scope), revision: this
|
|
264
|
+
next = { peer, state, access: [...access], read_scope: patch.read_scope === undefined ? (current?.read_scope ?? {}) : requireScope(patch.read_scope), revision: this.#ctx.id() };
|
|
223
265
|
}
|
|
224
266
|
await tx.put(key('member', ref.owner, ref.id, peer), next);
|
|
225
267
|
if (next.state === 'active' && current?.state !== 'active' && peer !== ref.owner) await issueGrant(tx, ref, peer, next.revision);
|
|
@@ -230,8 +272,8 @@ export class ThreadService {
|
|
|
230
272
|
|
|
231
273
|
async members(auth: Auth, ref: ThreadRef, options: PageOptions = {}): Promise<Page<Member>> {
|
|
232
274
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
233
|
-
return this
|
|
234
|
-
await loadThread(tx, this
|
|
275
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
276
|
+
await loadThread(tx, this.#ctx, ref);
|
|
235
277
|
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
|
|
236
278
|
const members = sortedBy((await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value), member => member.peer);
|
|
237
279
|
return page(members, options, { kind: 'members', ref }, member => member.peer);
|
|
@@ -240,8 +282,8 @@ export class ThreadService {
|
|
|
240
282
|
|
|
241
283
|
async defaultThread(auth: Auth): Promise<ThreadRef> {
|
|
242
284
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
243
|
-
this
|
|
244
|
-
return this
|
|
285
|
+
this.#ctx.owner(auth.actor);
|
|
286
|
+
return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<ThreadRef>(key('default', auth.actor))));
|
|
245
287
|
}
|
|
246
288
|
|
|
247
289
|
/**
|
|
@@ -251,8 +293,8 @@ export class ThreadService {
|
|
|
251
293
|
*/
|
|
252
294
|
async notices(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
|
|
253
295
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
254
|
-
this
|
|
255
|
-
return this
|
|
296
|
+
this.#ctx.owner(auth.actor);
|
|
297
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
256
298
|
const held = sortedBy((await tx.scan<Grant>(key('notice') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
|
|
257
299
|
return page(held, options, { kind: 'notices', owner: auth.actor }, grant => grant.id);
|
|
258
300
|
});
|
|
@@ -265,7 +307,7 @@ export class ThreadService {
|
|
|
265
307
|
*/
|
|
266
308
|
async grants(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
|
|
267
309
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
268
|
-
return this
|
|
310
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
269
311
|
const held = sortedBy((await tx.scan<Grant>(key('grant') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
|
|
270
312
|
return page(held, options, { kind: 'grants', recipient: auth.actor }, grant => grant.id);
|
|
271
313
|
});
|
|
@@ -274,7 +316,7 @@ export class ThreadService {
|
|
|
274
316
|
/** Recipient side: records that the owner confirmed this grant, so it is not confirmed twice. */
|
|
275
317
|
async ackGrantLocally(auth: Auth, ref: ThreadRef, id: string): Promise<void> {
|
|
276
318
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
277
|
-
await this
|
|
319
|
+
await this.#ctx.storage.transaction(async tx => {
|
|
278
320
|
const slot = key('grant', ref.owner, id);
|
|
279
321
|
const stored = await tx.get<Grant>(slot);
|
|
280
322
|
if (stored !== undefined && !stored.acked) await tx.put(slot, { ...stored, acked: true });
|
|
@@ -285,6 +327,10 @@ export class ThreadService {
|
|
|
285
327
|
* Recipient side of grant acceptance: an owner pushes us a notice we received during
|
|
286
328
|
* a prior join. We store both the notice and the known-owner record keyed owner+threadid.
|
|
287
329
|
* Caller is the owner the notice names, notice is addressed to our node, thread stays on owner.
|
|
330
|
+
*
|
|
331
|
+
* A notice that names `previous_thread_ref` is a re-issue after its owner changed identity:
|
|
332
|
+
* the ref it names is the one this node learned before, so that known row is dropped. Two
|
|
333
|
+
* rows for one thread would make every reconciliation pass pull the dead owner.
|
|
288
334
|
*/
|
|
289
335
|
async acceptGrant(auth: Auth, grant: Grant): Promise<Grant> {
|
|
290
336
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
@@ -293,18 +339,31 @@ export class ThreadService {
|
|
|
293
339
|
}
|
|
294
340
|
requireThreadRef(grant.thread_ref);
|
|
295
341
|
if (grant.thread_ref.owner !== auth.actor) fail('forbidden', 'A grant notice must come from the thread owner it names.');
|
|
296
|
-
if (grant.recipient !== this
|
|
342
|
+
if (grant.recipient !== this.#ctx.node) fail('forbidden', 'The grant notice is addressed to another node.');
|
|
343
|
+
const previous = grant.previous_thread_ref;
|
|
344
|
+
if (previous !== undefined) requireThreadRef(previous);
|
|
297
345
|
const ref: ThreadRef = { owner: grant.thread_ref.owner, id: grant.thread_ref.id };
|
|
298
|
-
return this
|
|
346
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
299
347
|
const slot = key('grant', ref.owner, grant.id);
|
|
300
348
|
const stored = await tx.get<Grant>(slot);
|
|
301
|
-
if (stored !== undefined && (stored.recipient !== this
|
|
349
|
+
if (stored !== undefined && (stored.recipient !== this.#ctx.node || stored.revision !== grant.revision || stored.thread_ref.id !== ref.id)) {
|
|
302
350
|
fail('id_conflict', 'The grant id is bound to another notice.');
|
|
303
351
|
}
|
|
304
352
|
const known: Known = { owner: ref.owner, thread_ref: ref, revision: grant.revision };
|
|
305
353
|
await tx.put(key('known', ref.owner, ref.id), known);
|
|
354
|
+
if (previous !== undefined && (previous.owner !== ref.owner || previous.id !== ref.id)) {
|
|
355
|
+
await tx.delete(key('known', previous.owner, previous.id));
|
|
356
|
+
// The copied grant rows under the old owner are what the confirmation pass walks: left
|
|
357
|
+
// in place they keep confirming a notice the dead owner issued and keep the dead thread
|
|
358
|
+
// in this node's joined list. A re-issue replaces them, so drop every row that names it.
|
|
359
|
+
for (const row of await tx.scan<Grant>(key('grant') + '/')) {
|
|
360
|
+
if (row.value.thread_ref.owner === previous.owner && row.value.thread_ref.id === previous.id) {
|
|
361
|
+
await tx.delete(row.key);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
306
365
|
if (stored !== undefined) return stored;
|
|
307
|
-
const next: Grant = { id: grant.id, recipient: this
|
|
366
|
+
const next: Grant = { id: grant.id, recipient: this.#ctx.node, thread_ref: ref, revision: grant.revision, acked: false };
|
|
308
367
|
await tx.put(slot, next);
|
|
309
368
|
return next;
|
|
310
369
|
});
|
|
@@ -318,7 +377,7 @@ export class ThreadService {
|
|
|
318
377
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
319
378
|
if (typeof id !== 'string' || id.length === 0) fail('invalid_request', 'A grant id is required.');
|
|
320
379
|
const body = { id };
|
|
321
|
-
return this
|
|
380
|
+
return this.#ctx.mutate(auth, `thread.grant.ack/${id}`, mutation, body, async tx => {
|
|
322
381
|
const grant = requireValue(await tx.get<Grant>(key('notice', id)));
|
|
323
382
|
if (grant.recipient !== auth.actor) fail('forbidden', 'The grant notice is addressed to another recipient.');
|
|
324
383
|
if (grant.acked) return grant;
|
|
@@ -335,12 +394,12 @@ export class ThreadService {
|
|
|
335
394
|
if (original.sender !== auth.actor) fail('forbidden', 'Only the original message sender may issue a reply permit.');
|
|
336
395
|
if (typeof permit.grantee !== 'string' || !permit.grantee || permit.grantee === auth.actor) fail('invalid_request', 'A permit names one grantee other than its issuer.');
|
|
337
396
|
requireThreadRef(permit.landing);
|
|
338
|
-
this
|
|
397
|
+
this.#ctx.owner(permit.landing.owner);
|
|
339
398
|
if (!Number.isInteger(permit.remaining) || permit.remaining < 1) fail('invalid_request', 'A permit needs a bounded positive reply count.');
|
|
340
|
-
if (utc(permit.expires_at) <= this
|
|
399
|
+
if (utc(permit.expires_at) <= this.#ctx.now()) fail('invalid_request', 'A permit needs a future expiry.');
|
|
341
400
|
const body = { message_ref: { sender: original.sender, id: original.id }, grantee: permit.grantee, landing: permit.landing, expires_at: permit.expires_at, remaining: permit.remaining, followup: permit.followup === true };
|
|
342
|
-
return this
|
|
343
|
-
const thread = await loadThread(tx, this
|
|
401
|
+
return this.#ctx.mutate(auth, `thread.permit/${original.sender}/${original.id}/${permit.grantee}`, mutation, body, async tx => {
|
|
402
|
+
const thread = await loadThread(tx, this.#ctx, permit.landing);
|
|
344
403
|
if (thread.state === 'closed') fail('thread_closed', 'A permit cannot land on a closed thread.');
|
|
345
404
|
const stored: ReplyPermit = { ...body, revoked: permit.revoked === true };
|
|
346
405
|
await tx.put(key('permit', original.sender, original.id, permit.grantee), stored);
|
package/src/transport.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { OnlyneError } from './core.
|
|
2
|
-
import type { ErrorCode, Failure, NodeId, Target, Transport, TrustEntry } from './types.
|
|
3
|
-
import type { TrustService } from './keys.
|
|
1
|
+
import { OnlyneError } from './core.ts';
|
|
2
|
+
import type { ErrorCode, Failure, NodeId, Target, Transport, TrustEntry } from './types.ts';
|
|
3
|
+
import type { TrustService } from './keys.ts';
|
|
4
4
|
|
|
5
5
|
/** The trust reads and signing steps a transport performs; a host that builds its own node wraps the real service. */
|
|
6
6
|
export type SigningTrust = Pick<TrustService, 'entry' | 'signRequest' | 'verifyResponse'>;
|
|
@@ -64,11 +64,15 @@ function failureRecord(parsed: unknown): unknown {
|
|
|
64
64
|
/**
|
|
65
65
|
* RFC 0017 §3: what a signed request hits when this node holds no trusted key for the peer.
|
|
66
66
|
* Both node ids travel in the text and in `details`, because a missing key here says nothing
|
|
67
|
-
* about whether the peer runs. The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* about whether the peer runs. The runtime re-sends the record that stays open, so the code is
|
|
68
|
+
* `unavailable` and the message keeps that retry apart from an agent repeating the send.
|
|
69
|
+
*
|
|
70
|
+
* A pending exchange is not a task for the reader: the invitation is already recorded on the
|
|
71
|
+
* side that owes the approval, and the acquisition policy there submits the approval on the
|
|
72
|
+
* next attempt, or that node's holder confirms it (RFC 0018 §4). The text says that instead of
|
|
73
|
+
* prescribing a menu step, because with policy `all` or `segment` no one has to act. The
|
|
74
|
+
* `no_record` and `revoked` cases keep the host's `guidance` menu, since nothing records them
|
|
75
|
+
* yet and only a holder can start or restore the exchange.
|
|
72
76
|
*/
|
|
73
77
|
function missingTrust(
|
|
74
78
|
self: NodeId | null, target: NodeId, peer: TrustEntry | undefined,
|
|
@@ -85,8 +89,11 @@ function missingTrust(
|
|
|
85
89
|
const surface = guide === undefined ? '' : `: ${guide.entry.join(' → ')}`;
|
|
86
90
|
const failure = guide === undefined ? details : { ...details, entry: guide.entry };
|
|
87
91
|
if (reason === 'no_record') {
|
|
92
|
+
// The exchange has a sanctioned start that is not a human step: `trust_begin` on this
|
|
93
|
+
// node, judged by this node's acquisition policy, and the peer's policy judges the
|
|
94
|
+
// statement that arrives. Naming only the panel hid that path (tern host report).
|
|
88
95
|
return new OnlyneError('unavailable',
|
|
89
|
-
`${here} holds no trust record for ${target}, so this request cannot be signed.
|
|
96
|
+
`${here} holds no trust record for ${target}, so this request cannot be signed. Start the exchange with trust_begin, which the acquisition policy on ${here} judges; ${target} can also start one, and this node's policy judges the arriving statement. A holder can complete the pairing through this node's Onlyne surface${surface}. ${retry}`,
|
|
90
97
|
failure);
|
|
91
98
|
}
|
|
92
99
|
if (reason === 'revoked') {
|
|
@@ -95,8 +102,8 @@ function missingTrust(
|
|
|
95
102
|
failure);
|
|
96
103
|
}
|
|
97
104
|
const message = peer!.local_approved
|
|
98
|
-
? `${target} has not answered the exchange ${here} requested, so this request cannot be signed yet.
|
|
99
|
-
: `${target} requested an exchange with ${here} and this node has not approved it, so this request cannot be signed yet.
|
|
105
|
+
? `${target} has not answered the exchange ${here} requested, so this request cannot be signed yet. The invitation is recorded at ${target} as pending; the acquisition policy on that node submits the approval on the next attempt, or its holder confirms it${surface}. ${retry}`
|
|
106
|
+
: `${target} requested an exchange with ${here} and this node has not approved it, so this request cannot be signed yet. The invitation is recorded here as pending; this node's acquisition policy submits the approval on the next attempt, or its holder confirms it${surface}. ${retry}`;
|
|
100
107
|
return new OnlyneError('unavailable', message, failure);
|
|
101
108
|
}
|
|
102
109
|
|
package/src/types.ts
CHANGED
|
@@ -77,6 +77,28 @@ export interface Thread {
|
|
|
77
77
|
event_seq: string; created_at: string; closed_at: string | null;
|
|
78
78
|
messages_until?: string;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A thread this node was invited into and does not own. Every field here comes from a record
|
|
82
|
+
* this node already holds; the owner's title, state and member table are filled in only by a
|
|
83
|
+
* caller that could ask the owner, and a row that could not ask keeps the failure instead of
|
|
84
|
+
* inventing them.
|
|
85
|
+
*/
|
|
86
|
+
export interface JoinedThread {
|
|
87
|
+
thread_ref: ThreadRef;
|
|
88
|
+
/** The highest sequence this node holds a local record for; `0` when it holds none. */
|
|
89
|
+
last_seq: string;
|
|
90
|
+
/** Distinct messages this node holds for the thread. */
|
|
91
|
+
message_count: number;
|
|
92
|
+
/** A participant never owns the member table, so this view is read-only by construction. */
|
|
93
|
+
read_only: true;
|
|
94
|
+
title?: string;
|
|
95
|
+
state?: Thread['state'];
|
|
96
|
+
members?: Member[];
|
|
97
|
+
/** The owner could not be asked; the row stays listed with what the local records prove. */
|
|
98
|
+
unavailable?: Failure;
|
|
99
|
+
}
|
|
100
|
+
/** One thread record with its member table: what the describe read answers, here or at the owner. */
|
|
101
|
+
export interface ThreadDescription { thread: Thread; members: Member[] }
|
|
80
102
|
export interface Attention {
|
|
81
103
|
obligation_id: string; recipient: NodeId; thread_ref: ThreadRef; seq: string;
|
|
82
104
|
message_ref: MessageRef; authenticated_author: NodeId; acked: boolean;
|
|
@@ -86,6 +108,12 @@ export interface Attention {
|
|
|
86
108
|
}
|
|
87
109
|
export interface Grant {
|
|
88
110
|
id: string; recipient: NodeId; thread_ref: ThreadRef; revision: string; acked: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Set on a re-issued notice: the ref the recipient learned under the previous owner. The
|
|
113
|
+
* recipient stores the new known row and drops the one this names, so a renamed owner does
|
|
114
|
+
* not leave a second row pointing at an endpoint that no longer exists.
|
|
115
|
+
*/
|
|
116
|
+
previous_thread_ref?: ThreadRef;
|
|
89
117
|
}
|
|
90
118
|
export interface Event {
|
|
91
119
|
id: string; type: 'message' | 'membership' | 'thread_closed' | 'resource_event' | 'operation';
|
|
@@ -139,11 +167,16 @@ export interface OperationRecord {
|
|
|
139
167
|
export interface Outbound {
|
|
140
168
|
request: SendRequest; expected_author: NodeId; state: 'pending' | 'accepted' | 'refused' | 'unknown';
|
|
141
169
|
receipt?: Receipt; error?: Failure; created_at?: string;
|
|
170
|
+
/** The peer this send addresses when it differs from the endpoint that stores the entry. */
|
|
171
|
+
addressee?: NodeId;
|
|
172
|
+
/** The caller's own spelling of the addressee, when it differs from the resolved node id. */
|
|
173
|
+
requested?: string;
|
|
142
174
|
}
|
|
143
175
|
export interface HistoryMessage {
|
|
144
176
|
message_ref: MessageRef; message: Message; direction: 'in' | 'out';
|
|
145
177
|
thread_ref?: ThreadRef; seq?: string; at: string | null;
|
|
146
178
|
delivery: Outbound['state'] | 'received'; consumption?: 'pending' | 'consumed';
|
|
179
|
+
settlement?: AttentionSettlement;
|
|
147
180
|
guarantee?: Storage['guarantee']; error?: Failure;
|
|
148
181
|
}
|
|
149
182
|
export interface Conversation {
|
|
@@ -151,15 +184,17 @@ export interface Conversation {
|
|
|
151
184
|
last_at: string | null;
|
|
152
185
|
}
|
|
153
186
|
export interface HistoryQuery extends PageOptions { thread?: ThreadRef; message?: MessageRef }
|
|
187
|
+
export type AttentionSettlement =
|
|
188
|
+
| { via: 'handover'; resource_ref: ResourceRef; handler: NodeId; revision: string; at: string }
|
|
189
|
+
| { via: 'undeliverable'; resource_ref: ResourceRef; reason: 'resource-unavailable'; at: string };
|
|
154
190
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* host has seen the entry in a provider request. A message submitted into a session that never
|
|
158
|
-
* reads it therefore stays `injected: false` and keeps its place in unread and alerts
|
|
159
|
-
* (caerulea report 3, problem 1). The name predates that rule and is kept because renaming it
|
|
160
|
-
* would read every stored row as unobserved and re-inject what was already read.
|
|
191
|
+
* `injected` closes the local delivery obligation after observed consumption or an explicit
|
|
192
|
+
* settlement. A settlement records why delivery ended without claiming model consumption.
|
|
161
193
|
*/
|
|
162
|
-
export interface LocalAttention {
|
|
194
|
+
export interface LocalAttention {
|
|
195
|
+
attention: Attention; context?: string; injected: boolean; settlement?: AttentionSettlement;
|
|
196
|
+
resource_check_failures?: number;
|
|
197
|
+
}
|
|
163
198
|
export interface Target { node_id: NodeId; base_url: string; headers?: Record<string, string> }
|
|
164
199
|
export interface Transport {
|
|
165
200
|
request<T>(target: NodeId, method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
|
|
@@ -193,11 +228,19 @@ export type TraceEvent =
|
|
|
193
228
|
| { event: 'inbound.duplicate'; sender: NodeId; message: string }
|
|
194
229
|
| { event: 'inbound.acked'; obligation: string; thread: ThreadRef; seq: string }
|
|
195
230
|
| { event: 'inbound.inject'; thread: ThreadRef; seq: string; context: string; outcome: InjectOutcome; reason?: string }
|
|
231
|
+
| { event: 'inbound.settled'; thread: ThreadRef; seq: string; settlement: AttentionSettlement }
|
|
232
|
+
| { event: 'inbound.resource-check.failed'; thread: ThreadRef; seq: string; resource_ref: ResourceRef; code: ErrorCode; attempts: number }
|
|
196
233
|
| { event: 'consumption.confirmed'; thread: ThreadRef; seq: string; context: string; via: 'fragment' | 'tool_result' }
|
|
197
|
-
| { event: 'outbound.admitted'; sender: NodeId; message: string; target: string; state: string; guarantee?: string; code?: string }
|
|
234
|
+
| { event: 'outbound.admitted'; sender: NodeId; message: string; target: string; state: string; guarantee?: string; code?: string; resolved?: NodeId }
|
|
198
235
|
| { event: 'outbound.redriven'; sender: NodeId; message: string; target: string }
|
|
199
236
|
| { event: 'outbound.expired'; sender: NodeId; message: string; code: string }
|
|
200
|
-
| { event: 'await.settled'; thread: ThreadRef; seq: string; waiters: number }
|
|
237
|
+
| { event: 'await.settled'; thread: ThreadRef; seq: string; waiters: number }
|
|
238
|
+
/**
|
|
239
|
+
* A known owner this node could not reconcile, written at most once per (owner, thread, code)
|
|
240
|
+
* per window. `attempts` is how many failed passes that one line accounts for, the suppressed
|
|
241
|
+
* duplicates included; the local rows a failed pull never reached are still owed, not dropped.
|
|
242
|
+
*/
|
|
243
|
+
| { event: 'reconcile.failed'; owner: NodeId; thread: string; code: ErrorCode; message: string; attempts: number };
|
|
201
244
|
/** What the host did with one inbound entry, reported back with the injection result. */
|
|
202
245
|
export type InjectOutcome = 'delivered' | 'queued' | 'waiting' | 'skipped' | 'consumed';
|
|
203
246
|
export interface Runtime {
|
|
@@ -331,6 +374,8 @@ export interface Plugins {
|
|
|
331
374
|
resolve(node: NodeId): Promise<Target>;
|
|
332
375
|
decide(input: MsgInput): Promise<Action>;
|
|
333
376
|
runtime?: Runtime;
|
|
377
|
+
/** When true, candidate exchange allows loopback addresses (useful in local dev & testing). */
|
|
378
|
+
allow_loopback?: boolean;
|
|
334
379
|
/** Supplied when the host wants the library to own the maintenance loop (RFC 0017 §6). */
|
|
335
380
|
maintenanceClock?: MaintenanceClock;
|
|
336
381
|
resources?: ResourceDriver;
|