@onlyne/lib 0.0.0-stage → 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/threads.ts ADDED
@@ -0,0 +1,409 @@
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
+
5
+ /** One reserved default-thread id per owner: omitted targets land here, and the id is never reused. */
6
+ export const DEFAULT_THREAD = 'thr-default';
7
+
8
+ const DEFAULT_ACCESS: Member['access'] = ['post', 'read'];
9
+
10
+ function requireThreadRef(ref: ThreadRef): void {
11
+ if (ref == null || typeof ref !== 'object' || typeof ref.owner !== 'string' || typeof ref.id !== 'string' || ref.id.length === 0) {
12
+ fail('invalid_request', 'Expected a ThreadRef with an owner and a non-empty id.');
13
+ }
14
+ }
15
+
16
+ async function loadThread(tx: Transaction, ctx: Context, ref: ThreadRef): Promise<Thread> {
17
+ requireThreadRef(ref);
18
+ ctx.owner(ref.owner);
19
+ return requireValue(await tx.get<Thread>(key('thread', ref.owner, ref.id)));
20
+ }
21
+
22
+ function requireAccess(access: Array<'read' | 'post'>): Member['access'] {
23
+ if (!Array.isArray(access) || access.length === 0) fail('invalid_request', 'access must list at least one of post and read.');
24
+ for (const item of access) if (item !== 'post' && item !== 'read') fail('invalid_request', 'Unknown access member; expected post or read.');
25
+ return [...new Set(access)];
26
+ }
27
+
28
+ function requireScope(scope: ReadScope): ReadScope {
29
+ if (scope == null || typeof scope !== 'object' || Array.isArray(scope)) fail('invalid_request', 'read_scope must be an object.');
30
+ for (const name of Object.keys(scope)) {
31
+ if (name !== 'peers' && name !== 'since_seq' && name !== 'related_to') fail('invalid_request', `Unknown read_scope key ${name}.`);
32
+ }
33
+ const { peers, since_seq, related_to } = scope;
34
+ if (peers !== undefined && (!Array.isArray(peers) || peers.length === 0 || peers.some(peer => typeof peer !== 'string' || peer.length === 0))) {
35
+ fail('invalid_request', 'read_scope.peers must be a non-empty array of node ids.');
36
+ }
37
+ if (since_seq !== undefined && (typeof since_seq !== 'string' || !/^(0|[1-9][0-9]*)$/.test(since_seq))) {
38
+ fail('invalid_request', 'read_scope.since_seq must be a decimal sequence string.');
39
+ }
40
+ if (related_to !== undefined && (typeof related_to?.sender !== 'string' || !related_to.sender || typeof related_to.id !== 'string' || !related_to.id)) {
41
+ fail('invalid_request', 'read_scope.related_to must be a complete MessageRef.');
42
+ }
43
+ return { ...(peers === undefined ? {} : { peers: [...new Set(peers)] }), ...(since_seq === undefined ? {} : { since_seq }), ...(related_to === undefined ? {} : { related_to: { sender: related_to.sender, id: related_to.id } }) };
44
+ }
45
+
46
+ function inScope(entry: Entry, scope: ReadScope): boolean {
47
+ if (scope.peers !== undefined && !scope.peers.includes(entry.authenticated_author)) return false;
48
+ if (scope.since_seq !== undefined && BigInt(entry.seq) < BigInt(scope.since_seq)) return false;
49
+ if (scope.related_to !== undefined) {
50
+ const related = entry.message.meta?.in_reply_to;
51
+ if (!related || related.sender !== scope.related_to.sender || related.id !== scope.related_to.id) return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ function freshThread(ref: ThreadRef, title: string, at: string): Thread {
57
+ return { thread_ref: ref, title, state: 'open', next_seq: '1', event_seq: '0', created_at: at, closed_at: null };
58
+ }
59
+
60
+ async function recordEvent(tx: Transaction, thread: Thread, type: Event['type'], data: Json): Promise<Thread> {
61
+ const seq = (BigInt(thread.event_seq) + 1n).toString();
62
+ const next: Thread = { ...thread, event_seq: seq };
63
+ await tx.put(key('thread', thread.thread_ref.owner, thread.thread_ref.id), next);
64
+ const event: Event = { id: seq, type, thread_ref: thread.thread_ref, data };
65
+ await tx.put(key('event', thread.thread_ref.owner, thread.thread_ref.id, seq), event);
66
+ return next;
67
+ }
68
+
69
+ /** Ascending by a unique string position, which is what core.page resumes on. */
70
+ function sortedBy<T>(items: T[], position: (item: T) => string): T[] {
71
+ return items.sort((left, right) => {
72
+ const a = position(left);
73
+ const b = position(right);
74
+ return a < b ? -1 : a > b ? 1 : 0;
75
+ });
76
+ }
77
+
78
+ /** Reads a thread record under the caller's authority over its owner. */
79
+ export async function getThread(tx: Transaction, ctx: Context, ref: ThreadRef): Promise<Thread> {
80
+ return loadThread(tx, ctx, ref);
81
+ }
82
+
83
+ /**
84
+ * Read authority for a thread: the owner reads everything; any other actor reads
85
+ * only as an active member holding read whose current read_scope admits the entry.
86
+ * The scope is evaluated at read time, never stored on the entry.
87
+ */
88
+ export async function canRead(tx: Transaction, actor: string, ref: ThreadRef, entry?: Entry): Promise<boolean> {
89
+ const thread = await tx.get<Thread>(key('thread', ref.owner, ref.id));
90
+ if (actor === thread?.thread_ref.owner) return true;
91
+ if (entry !== undefined && (entry.thread_ref.owner !== ref.owner || entry.thread_ref.id !== ref.id)) return false;
92
+ const member = await tx.get<Member>(key('member', ref.owner, ref.id, actor));
93
+ if (member?.state !== 'active' || !member.access.includes('read')) return false;
94
+ return entry === undefined || inScope(entry, member.read_scope);
95
+ }
96
+
97
+ /**
98
+ * Issues the persistent grant notice an owner keeps until the recipient confirms it.
99
+ * The notice id is the member revision, so one revision maps to one notice and a
100
+ * retried membership change cannot queue a second delivery.
101
+ */
102
+ async function issueGrant(tx: Transaction, ref: ThreadRef, peer: string, revision: string): Promise<Grant> {
103
+ const grant: Grant = { id: revision, recipient: peer, thread_ref: ref, revision, acked: false };
104
+ await tx.put(key('notice', grant.id), grant);
105
+ return grant;
106
+ }
107
+
108
+ /** What a recipient keeps once an owner grants access, independent of the notice ack. */
109
+ interface Known {
110
+ owner: string;
111
+ thread_ref: ThreadRef;
112
+ revision: string;
113
+ }
114
+
115
+ export class ThreadService {
116
+ readonly #ctx: Context;
117
+
118
+ constructor(ctx: Context) {
119
+ this.#ctx = ctx;
120
+ }
121
+
122
+ async create(auth: Auth, mutation: Mutation & { title: string; id?: string; default?: boolean }): Promise<Thread> {
123
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
124
+ const owner = auth.actor;
125
+ this.#ctx.owner(owner);
126
+ if (typeof mutation.title !== 'string' || mutation.title.length === 0) fail('invalid_request', 'A thread needs a title.');
127
+ if (mutation.id !== undefined && (typeof mutation.id !== 'string' || mutation.id.length === 0)) fail('invalid_request', 'Thread id must be a non-empty string.');
128
+ const wantsDefault = mutation.default === true || mutation.id === DEFAULT_THREAD;
129
+ const body = { title: mutation.title, id: mutation.id ?? null, default: wantsDefault };
130
+ return this.#ctx.mutate(auth, 'thread.create', mutation, body, async tx => {
131
+ const at = new Date(this.#ctx.now()).toISOString();
132
+ const existingDefault = await tx.get<Thread>(key('thread', owner, DEFAULT_THREAD));
133
+ if (wantsDefault) {
134
+ if (existingDefault) {
135
+ if (existingDefault.title === mutation.title || mutation.id === undefined) return existingDefault;
136
+ const retitled = { ...existingDefault, title: mutation.title };
137
+ await tx.put(key('thread', owner, DEFAULT_THREAD), retitled);
138
+ return retitled;
139
+ }
140
+ const thread = freshThread({ owner, id: DEFAULT_THREAD }, mutation.title, at);
141
+ await tx.put(key('thread', owner, DEFAULT_THREAD), thread);
142
+ await tx.put(key('default', owner), thread.thread_ref);
143
+ return thread;
144
+ }
145
+ let ref: ThreadRef;
146
+ if (mutation.id === undefined) {
147
+ ref = { owner, id: crypto.randomUUID() };
148
+ while (await tx.get(key('thread', owner, ref.id))) ref = { owner, id: crypto.randomUUID() };
149
+ } else {
150
+ ref = { owner, id: mutation.id };
151
+ if (await tx.get(key('thread', owner, ref.id)) || ref.id === DEFAULT_THREAD) fail('id_conflict', 'This thread id is already allocated for the owner.');
152
+ }
153
+ const thread = freshThread(ref, mutation.title, at);
154
+ await tx.put(key('thread', owner, ref.id), thread);
155
+ // An ordinary thread does not plant the landing slot: a default exists only when the
156
+ // owner asks for one, so opening a conversation never turns later first contacts into
157
+ // `not_a_member` refusals against a thread nobody joined.
158
+ return thread;
159
+ });
160
+ }
161
+
162
+ async close(auth: Auth, ref: ThreadRef, mutation: Mutation): Promise<Thread> {
163
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
164
+ requireThreadRef(ref);
165
+ this.#ctx.owner(ref.owner);
166
+ if (auth.actor !== ref.owner) fail('forbidden', 'Only the owner may close a thread.');
167
+ const body = { ref };
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);
170
+ if (thread.state === 'closed') fail('thread_closed', 'The thread is already closed.');
171
+ return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this.#ctx.now()).toISOString() }, 'thread_closed', {});
172
+ });
173
+ }
174
+
175
+ async list(auth: Auth, options: PageOptions = {}): Promise<Page<Thread>> {
176
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
177
+ this.#ctx.owner(auth.actor);
178
+ return this.#ctx.storage.transaction(async tx => {
179
+ const threads = sortedBy((await tx.scan<Thread>(key('thread', auth.actor) + '/')).map(row => row.value), thread => thread.thread_ref.id);
180
+ return page(threads, options, { kind: 'threads', owner: auth.actor }, thread => thread.thread_ref.id);
181
+ });
182
+ }
183
+
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>> {
193
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
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);
226
+ if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
227
+ const members = (await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value);
228
+ return { thread, members };
229
+ });
230
+ }
231
+
232
+ async member(auth: Auth, ref: ThreadRef, peer: string, patch: Partial<Member>, mutation: Mutation): Promise<Member> {
233
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
234
+ requireThreadRef(ref);
235
+ if (typeof peer !== 'string' || peer.length === 0) fail('invalid_request', 'A membership names its peer.');
236
+ const isOwner = auth.actor === ref.owner;
237
+ if (!isOwner && auth.actor !== peer) fail('forbidden', 'Only the owner manages other peers.');
238
+ if (patch.revision !== undefined) fail('invalid_request', 'The revision is issued by the owner.');
239
+ if (!isOwner && (patch.access !== undefined || patch.read_scope !== undefined || (patch.state !== undefined && patch.state !== 'revoked' && patch.state !== 'active'))) {
240
+ fail('forbidden', 'A peer may only join or withdraw itself.');
241
+ }
242
+ const body = { ref, peer, patch };
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);
245
+ const current = await tx.get<Member>(key('member', ref.owner, ref.id, peer));
246
+ let next: Member;
247
+ if (!isOwner) {
248
+ if (current === undefined) {
249
+ if (patch.state === 'revoked') fail('not_found', 'No membership record to withdraw.');
250
+ if (thread.state === 'closed') fail('thread_closed', 'A closed thread does not accept joins.');
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() };
253
+ } else if (patch.state === 'revoked') {
254
+ if (current.state !== 'active' && current.state !== 'requested') fail('conflict', 'This membership is already stopped.');
255
+ next = { ...current, state: 'revoked', access: [], revision: this.#ctx.id() };
256
+ } else {
257
+ fail('conflict', 'This peer already holds a membership record; only the owner changes it.');
258
+ }
259
+ } else {
260
+ const state = patch.state ?? 'active';
261
+ if (state !== 'requested' && state !== 'active' && state !== 'revoked' && state !== 'denied') fail('invalid_request', 'Unknown member state.');
262
+ const stopping = state === 'revoked' || state === 'denied';
263
+ const access = stopping ? [] : patch.access === undefined ? (current?.state === 'active' ? current.access : [...DEFAULT_ACCESS]) : requireAccess(patch.access);
264
+ next = { peer, state, access: [...access], read_scope: patch.read_scope === undefined ? (current?.read_scope ?? {}) : requireScope(patch.read_scope), revision: this.#ctx.id() };
265
+ }
266
+ await tx.put(key('member', ref.owner, ref.id, peer), next);
267
+ if (next.state === 'active' && current?.state !== 'active' && peer !== ref.owner) await issueGrant(tx, ref, peer, next.revision);
268
+ await recordEvent(tx, thread, 'membership', JSON.parse(JSON.stringify(next)) as Json);
269
+ return next;
270
+ });
271
+ }
272
+
273
+ async members(auth: Auth, ref: ThreadRef, options: PageOptions = {}): Promise<Page<Member>> {
274
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
275
+ return this.#ctx.storage.transaction(async tx => {
276
+ await loadThread(tx, this.#ctx, ref);
277
+ if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
278
+ const members = sortedBy((await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value), member => member.peer);
279
+ return page(members, options, { kind: 'members', ref }, member => member.peer);
280
+ });
281
+ }
282
+
283
+ async defaultThread(auth: Auth): Promise<ThreadRef> {
284
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
285
+ this.#ctx.owner(auth.actor);
286
+ return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<ThreadRef>(key('default', auth.actor))));
287
+ }
288
+
289
+ /**
290
+ * Owner-side notice outbox: every grant notice this node issued that has no ack yet.
291
+ * Only the owner calling as ctx.node may read this list; each item carries recipient
292
+ * so the client knows where to POST the full notice.
293
+ */
294
+ async notices(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
295
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
296
+ this.#ctx.owner(auth.actor);
297
+ return this.#ctx.storage.transaction(async tx => {
298
+ const held = sortedBy((await tx.scan<Grant>(key('notice') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
299
+ return page(held, options, { kind: 'notices', owner: auth.actor }, grant => grant.id);
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Recipient view: the grants this node accepted from owners, until it confirms each one.
305
+ * A missed push is fetched over HTTP and processed with acceptGrant; this list is what
306
+ * the confirmation pass walks to close the owner's notice.
307
+ */
308
+ async grants(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
309
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
310
+ return this.#ctx.storage.transaction(async tx => {
311
+ const held = sortedBy((await tx.scan<Grant>(key('grant') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
312
+ return page(held, options, { kind: 'grants', recipient: auth.actor }, grant => grant.id);
313
+ });
314
+ }
315
+
316
+ /** Recipient side: records that the owner confirmed this grant, so it is not confirmed twice. */
317
+ async ackGrantLocally(auth: Auth, ref: ThreadRef, id: string): Promise<void> {
318
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
319
+ await this.#ctx.storage.transaction(async tx => {
320
+ const slot = key('grant', ref.owner, id);
321
+ const stored = await tx.get<Grant>(slot);
322
+ if (stored !== undefined && !stored.acked) await tx.put(slot, { ...stored, acked: true });
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Recipient side of grant acceptance: an owner pushes us a notice we received during
328
+ * a prior join. We store both the notice and the known-owner record keyed owner+threadid.
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.
334
+ */
335
+ async acceptGrant(auth: Auth, grant: Grant): Promise<Grant> {
336
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
337
+ if (grant == null || typeof grant !== 'object' || typeof grant.id !== 'string' || grant.id.length === 0 || typeof grant.revision !== 'string' || grant.revision.length === 0) {
338
+ fail('invalid_request', 'Expected a grant notice with an id and a revision.');
339
+ }
340
+ requireThreadRef(grant.thread_ref);
341
+ if (grant.thread_ref.owner !== auth.actor) fail('forbidden', 'A grant notice must come from the thread owner it names.');
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);
345
+ const ref: ThreadRef = { owner: grant.thread_ref.owner, id: grant.thread_ref.id };
346
+ return this.#ctx.storage.transaction(async tx => {
347
+ const slot = key('grant', ref.owner, grant.id);
348
+ const stored = await tx.get<Grant>(slot);
349
+ if (stored !== undefined && (stored.recipient !== this.#ctx.node || stored.revision !== grant.revision || stored.thread_ref.id !== ref.id)) {
350
+ fail('id_conflict', 'The grant id is bound to another notice.');
351
+ }
352
+ const known: Known = { owner: ref.owner, thread_ref: ref, revision: grant.revision };
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
+ }
365
+ if (stored !== undefined) return stored;
366
+ const next: Grant = { id: grant.id, recipient: this.#ctx.node, thread_ref: ref, revision: grant.revision, acked: false };
367
+ await tx.put(slot, next);
368
+ return next;
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Acknowledge a notice on the owner node: owner receives our confirmation, updates
374
+ * the owner-issued notice to acked:true, stops retries. Caller must be recipient of the notice.
375
+ */
376
+ async ackGrant(auth: Auth, id: string, mutation: Mutation): Promise<Grant> {
377
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
378
+ if (typeof id !== 'string' || id.length === 0) fail('invalid_request', 'A grant id is required.');
379
+ const body = { id };
380
+ return this.#ctx.mutate(auth, `thread.grant.ack/${id}`, mutation, body, async tx => {
381
+ const grant = requireValue(await tx.get<Grant>(key('notice', id)));
382
+ if (grant.recipient !== auth.actor) fail('forbidden', 'The grant notice is addressed to another recipient.');
383
+ if (grant.acked) return grant;
384
+ const acked: Grant = { ...grant, acked: true };
385
+ await tx.put(key('notice', id), acked);
386
+ return acked;
387
+ });
388
+ }
389
+
390
+ async permits(auth: Auth, permit: ReplyPermit, mutation: Mutation): Promise<ReplyPermit> {
391
+ if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
392
+ const original: MessageRef | undefined = permit?.message_ref;
393
+ if (typeof original?.sender !== 'string' || !original.sender || typeof original.id !== 'string' || !original.id) fail('invalid_request', 'A permit binds a complete MessageRef.');
394
+ if (original.sender !== auth.actor) fail('forbidden', 'Only the original message sender may issue a reply permit.');
395
+ if (typeof permit.grantee !== 'string' || !permit.grantee || permit.grantee === auth.actor) fail('invalid_request', 'A permit names one grantee other than its issuer.');
396
+ requireThreadRef(permit.landing);
397
+ this.#ctx.owner(permit.landing.owner);
398
+ if (!Number.isInteger(permit.remaining) || permit.remaining < 1) fail('invalid_request', 'A permit needs a bounded positive reply count.');
399
+ if (utc(permit.expires_at) <= this.#ctx.now()) fail('invalid_request', 'A permit needs a future expiry.');
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 };
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);
403
+ if (thread.state === 'closed') fail('thread_closed', 'A permit cannot land on a closed thread.');
404
+ const stored: ReplyPermit = { ...body, revoked: permit.revoked === true };
405
+ await tx.put(key('permit', original.sender, original.id, permit.grantee), stored);
406
+ return stored;
407
+ });
408
+ }
409
+ }
@@ -0,0 +1,211 @@
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
+
5
+ /** The trust reads and signing steps a transport performs; a host that builds its own node wraps the real service. */
6
+ export type SigningTrust = Pick<TrustService, 'entry' | 'signRequest' | 'verifyResponse'>;
7
+
8
+ /**
9
+ * RFC 0017 §3: the pairing act a missing-trust failure names, before any host surface is
10
+ * known. `responsible_node` is the holder who still owes the act — this node when it never
11
+ * asked or must approve, the peer when it owes the answer — and `required_action` is what
12
+ * that holder does. A transport that cannot name its own node leaves `self` and the local
13
+ * cases' `responsible_node` null rather than guessing an identity.
14
+ */
15
+ export interface TrustFailure {
16
+ self: NodeId | null;
17
+ peer: NodeId;
18
+ reason: 'no_record' | 'pending' | 'revoked';
19
+ responsible_node: NodeId | null;
20
+ required_action: 'request_exchange' | 'confirm_exchange' | 'restore_trust';
21
+ }
22
+
23
+ /** RFC 0017 request signing: transcripts, response verification, trust-state gating. */
24
+ export interface SigningTransportOptions {
25
+ trust: SigningTrust;
26
+ /**
27
+ * Where this host's holder reaches the act a missing-trust failure names. The library knows
28
+ * which node owes the act and what it is; only the host knows its own menu, so the failure
29
+ * carries the host's path instead of one this file would have to invent. The path describes
30
+ * the responsible node's surface: the peer's own menu when that holder owes the answer.
31
+ */
32
+ guidance?(details: TrustFailure): { entry: string[] } | undefined;
33
+ }
34
+
35
+ // Loopback hosts may speak plain HTTP. Everything else reached by a resolver
36
+ // could be a remote peer, so it must be HTTPS: a cleartext binding would put
37
+ // bearer credentials on the wire.
38
+ const LOOPBACK: Record<string, true> = { '127.0.0.1': true, localhost: true, '[::1]': true };
39
+
40
+ /**
41
+ * The base URL a service request may use: HTTPS anywhere, plain HTTP on loopback only.
42
+ * Public reads (a candidate's service description) use the same rule, so one host cannot
43
+ * downgrade the other.
44
+ */
45
+ export function serviceBase(value: string): URL {
46
+ let base: URL;
47
+ try { base = new URL(value); } catch { throw new OnlyneError('unavailable', 'The endpoint is not an absolute URL.'); }
48
+ if (base.protocol !== 'https:' && !(base.protocol === 'http:' && LOOPBACK[base.hostname] === true)) {
49
+ throw new OnlyneError('unavailable', 'Plain HTTP is limited to loopback; cross-host peers must use HTTPS.');
50
+ }
51
+ return base;
52
+ }
53
+
54
+ function isFailure(value: unknown): value is Failure {
55
+ return typeof value === 'object' && value !== null && 'code' in value && 'message' in value && typeof value.code === 'string' && typeof value.message === 'string';
56
+ }
57
+
58
+ // Some bindings answer errors without the envelope when the request never
59
+ // reached service code; a bare failure record is still preserved.
60
+ function failureRecord(parsed: unknown): unknown {
61
+ return typeof parsed === 'object' && parsed !== null && 'error' in parsed ? parsed.error : parsed;
62
+ }
63
+
64
+ /**
65
+ * RFC 0017 §3: what a signed request hits when this node holds no trusted key for the peer.
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 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.
76
+ */
77
+ function missingTrust(
78
+ self: NodeId | null, target: NodeId, peer: TrustEntry | undefined,
79
+ guidance: SigningTransportOptions['guidance'],
80
+ ): OnlyneError {
81
+ const here = self ?? 'this node';
82
+ const retry = 'The runtime re-sends the open record once the pairing exists; do not re-send it yourself.';
83
+ const reason: TrustFailure['reason'] = peer === undefined ? 'no_record' : peer.state === 'revoked' ? 'revoked' : 'pending';
84
+ const required_action: TrustFailure['required_action'] = reason === 'pending' ? 'confirm_exchange' : reason === 'revoked' ? 'restore_trust' : 'request_exchange';
85
+ // A pending row says which holder still owes the act: ours when the peer asked, theirs when we did.
86
+ const responsible_node = reason === 'pending' && peer?.local_approved === true ? target : self;
87
+ const details = { self, peer: target, reason, responsible_node, required_action };
88
+ const guide = guidance?.(details);
89
+ const surface = guide === undefined ? '' : `: ${guide.entry.join(' → ')}`;
90
+ const failure = guide === undefined ? details : { ...details, entry: guide.entry };
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).
95
+ return new OnlyneError('unavailable',
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}`,
97
+ failure);
98
+ }
99
+ if (reason === 'revoked') {
100
+ return new OnlyneError('unavailable',
101
+ `${here} revoked ${target}, so this request cannot be signed. This agent cannot restore trust; the holder of ${here} pairs the two nodes again through this node's Onlyne surface${surface}. ${retry}`,
102
+ failure);
103
+ }
104
+ const message = peer!.local_approved
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}`;
107
+ return new OnlyneError('unavailable', message, failure);
108
+ }
109
+
110
+ export class FetchTransport implements Transport {
111
+ private readonly resolve: (node: NodeId) => Promise<Target>;
112
+ private readonly fetcher: typeof fetch;
113
+ private readonly signing: SigningTransportOptions | undefined;
114
+
115
+ constructor(resolve: (node: NodeId) => Promise<Target>, fetcher: typeof fetch = fetch, signing?: SigningTransportOptions) {
116
+ this.resolve = resolve;
117
+ this.fetcher = fetcher;
118
+ this.signing = signing;
119
+ }
120
+
121
+ async request<T>(target: NodeId, method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
122
+ const endpoint = await this.resolve(target);
123
+ if (endpoint.node_id !== target) {
124
+ throw new OnlyneError('unavailable', 'The resolver returned a target for another identity.');
125
+ }
126
+ const base = serviceBase(endpoint.base_url);
127
+ // The route is relative to base_url, which already carries the service
128
+ // prefix; a leading slash would silently drop that prefix.
129
+ const url = new URL('./' + path.replace(/^\/+/, ''), base.pathname.endsWith('/') ? base : base.href + '/');
130
+ if (url.origin !== base.origin) {
131
+ throw new OnlyneError('invalid_request', 'The request path leaves the endpoint origin.');
132
+ }
133
+
134
+ const headers = new Headers(endpoint.headers);
135
+ const serialized = body === undefined ? '' : JSON.stringify(body);
136
+ if (body !== undefined) {
137
+ headers.set('Content-Type', 'application/json');
138
+ // A server rejects the whole request when this header contradicts the
139
+ // body, so the key travels as a header only from the top-level field the
140
+ // body already carries; the body keeps its own copy.
141
+ if (typeof body === 'object' && body !== null && 'idempotency_key' in body && typeof body.idempotency_key === 'string' && body.idempotency_key !== '') {
142
+ headers.set('Idempotency-Key', body.idempotency_key);
143
+ }
144
+ }
145
+ if (this.signing !== undefined) {
146
+ const peer = await this.signing.trust.entry(target);
147
+ // No trusted key for this peer: the request cannot be signed, and sending it unsigned
148
+ // would be refused anyway. Report a retryable outage so the record stays open while the
149
+ // node holder completes the pairing, instead of recording a permanent refusal. The
150
+ // resolver supplies `Onlyne-Actor`, so the outbound request already names this node.
151
+ if (peer?.state !== 'trusted') {
152
+ throw missingTrust(headers.get('Onlyne-Actor'), target, peer, this.signing.guidance);
153
+ }
154
+ {
155
+ const signer = new Request(url.href, { method, headers });
156
+ await this.signing.trust.signRequest(signer, target, serialized);
157
+ for (const [name, value] of signer.headers) headers.set(name, value);
158
+ }
159
+ }
160
+
161
+ let response: Response;
162
+ const request = new Request(url.href, { method, headers });
163
+ try {
164
+ response = await this.fetcher(url, {
165
+ method,
166
+ headers,
167
+ body: body === undefined ? undefined : serialized,
168
+ signal,
169
+ });
170
+ } catch (error) {
171
+ // A consumer's cancellation is not a reachability fault; the caller sees
172
+ // its own abort reason instead of a retryable outage.
173
+ if (signal?.aborted) throw signal.reason;
174
+ throw new OnlyneError('unavailable', 'The target could not be reached.');
175
+ }
176
+
177
+ let text: string;
178
+ try {
179
+ text = await response.text();
180
+ } catch {
181
+ throw new OnlyneError('unavailable', 'The response body could not be read.');
182
+ }
183
+ let parsed: unknown;
184
+ try {
185
+ parsed = text === '' ? undefined : JSON.parse(text);
186
+ } catch {
187
+ parsed = undefined;
188
+ }
189
+
190
+ if (response.ok) {
191
+ if (parsed === undefined) throw new OnlyneError('unavailable', 'The target returned a malformed response.');
192
+ // A signed request is only ever sent to a trusted peer, so its answer must prove that
193
+ // identity; `verifyResponse` fails when the row is missing, untrusted or the signature
194
+ // does not cover this request's nonce.
195
+ if (this.signing !== undefined) await this.signing.trust.verifyResponse(target, request, response, text);
196
+ return parsed as T;
197
+ }
198
+
199
+ const record = failureRecord(parsed);
200
+ // A signed request is answered by a signed response even when it fails; an unsigned or
201
+ // forged error must not be read as the peer's refusal.
202
+ if (this.signing !== undefined) await this.signing.trust.verifyResponse(target, request, response, text);
203
+ if (isFailure(record)) {
204
+ const rejection = new OnlyneError(record.code as ErrorCode, record.message);
205
+ if (typeof record.retryable === 'boolean') rejection.error.retryable = record.retryable;
206
+ if (record.details !== undefined) rejection.error.details = record.details;
207
+ throw rejection;
208
+ }
209
+ throw new OnlyneError(response.status >= 500 ? 'unavailable' : 'internal', `The target answered HTTP ${response.status} without a failure record.`);
210
+ }
211
+ }