@onlyne/lib 0.0.0-stage → 3.0.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/LICENSE +21 -0
- package/README.md +91 -2
- package/README.zh-CN.md +92 -0
- package/package.json +30 -4
- package/src/client.ts +1549 -0
- package/src/core.ts +114 -0
- package/src/discovery.ts +267 -0
- package/src/domains.ts +210 -0
- package/src/history.ts +129 -0
- package/src/http.ts +841 -0
- package/src/index.ts +18 -0
- package/src/keys.ts +632 -0
- package/src/memory.ts +34 -0
- package/src/messages.ts +398 -0
- package/src/node.ts +56 -0
- package/src/resources.ts +745 -0
- package/src/threads.ts +350 -0
- package/src/transport.ts +204 -0
- package/src/types.ts +383 -0
package/src/threads.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { admissionView, fail, key, page, requireValue, utc } from './core.js';
|
|
2
|
+
import type { Context } from './core.js';
|
|
3
|
+
import type { Auth, Entry, Event, Grant, Json, Member, MessageRef, Mutation, Page, PageOptions, ReadScope, ReplyPermit, Thread, ThreadRef, Transaction } from './types.js';
|
|
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
|
+
constructor(private readonly ctx: Context) {}
|
|
117
|
+
|
|
118
|
+
async create(auth: Auth, mutation: Mutation & { title: string; id?: string; default?: boolean }): Promise<Thread> {
|
|
119
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
120
|
+
const owner = auth.actor;
|
|
121
|
+
this.ctx.owner(owner);
|
|
122
|
+
if (typeof mutation.title !== 'string' || mutation.title.length === 0) fail('invalid_request', 'A thread needs a title.');
|
|
123
|
+
if (mutation.id !== undefined && (typeof mutation.id !== 'string' || mutation.id.length === 0)) fail('invalid_request', 'Thread id must be a non-empty string.');
|
|
124
|
+
const wantsDefault = mutation.default === true || mutation.id === DEFAULT_THREAD;
|
|
125
|
+
const body = { title: mutation.title, id: mutation.id ?? null, default: wantsDefault };
|
|
126
|
+
return this.ctx.mutate(auth, 'thread.create', mutation, body, async tx => {
|
|
127
|
+
const at = new Date(this.ctx.now()).toISOString();
|
|
128
|
+
const existingDefault = await tx.get<Thread>(key('thread', owner, DEFAULT_THREAD));
|
|
129
|
+
if (wantsDefault) {
|
|
130
|
+
if (existingDefault) {
|
|
131
|
+
if (existingDefault.title === mutation.title || mutation.id === undefined) return existingDefault;
|
|
132
|
+
const retitled = { ...existingDefault, title: mutation.title };
|
|
133
|
+
await tx.put(key('thread', owner, DEFAULT_THREAD), retitled);
|
|
134
|
+
return retitled;
|
|
135
|
+
}
|
|
136
|
+
const thread = freshThread({ owner, id: DEFAULT_THREAD }, mutation.title, at);
|
|
137
|
+
await tx.put(key('thread', owner, DEFAULT_THREAD), thread);
|
|
138
|
+
await tx.put(key('default', owner), thread.thread_ref);
|
|
139
|
+
return thread;
|
|
140
|
+
}
|
|
141
|
+
let ref: ThreadRef;
|
|
142
|
+
if (mutation.id === undefined) {
|
|
143
|
+
ref = { owner, id: crypto.randomUUID() };
|
|
144
|
+
while (await tx.get(key('thread', owner, ref.id))) ref = { owner, id: crypto.randomUUID() };
|
|
145
|
+
} else {
|
|
146
|
+
ref = { owner, id: mutation.id };
|
|
147
|
+
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.');
|
|
148
|
+
}
|
|
149
|
+
const thread = freshThread(ref, mutation.title, at);
|
|
150
|
+
await tx.put(key('thread', owner, ref.id), thread);
|
|
151
|
+
// An ordinary thread does not plant the landing slot: a default exists only when the
|
|
152
|
+
// owner asks for one, so opening a conversation never turns later first contacts into
|
|
153
|
+
// `not_a_member` refusals against a thread nobody joined.
|
|
154
|
+
return thread;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async close(auth: Auth, ref: ThreadRef, mutation: Mutation): Promise<Thread> {
|
|
159
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
160
|
+
requireThreadRef(ref);
|
|
161
|
+
this.ctx.owner(ref.owner);
|
|
162
|
+
if (auth.actor !== ref.owner) fail('forbidden', 'Only the owner may close a thread.');
|
|
163
|
+
const body = { ref };
|
|
164
|
+
return this.ctx.mutate(auth, `thread.close/${ref.owner}/${ref.id}`, mutation, body, async tx => {
|
|
165
|
+
const thread = await loadThread(tx, this.ctx, ref);
|
|
166
|
+
if (thread.state === 'closed') fail('thread_closed', 'The thread is already closed.');
|
|
167
|
+
return recordEvent(tx, { ...thread, state: 'closed', closed_at: new Date(this.ctx.now()).toISOString() }, 'thread_closed', {});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async list(auth: Auth, options: PageOptions = {}): Promise<Page<Thread>> {
|
|
172
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
173
|
+
this.ctx.owner(auth.actor);
|
|
174
|
+
return this.ctx.storage.transaction(async tx => {
|
|
175
|
+
const threads = sortedBy((await tx.scan<Thread>(key('thread', auth.actor) + '/')).map(row => row.value), thread => thread.thread_ref.id);
|
|
176
|
+
return page(threads, options, { kind: 'threads', owner: auth.actor }, thread => thread.thread_ref.id);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async describe(auth: Auth, ref: ThreadRef): Promise<{ thread: Thread; members: Member[] }> {
|
|
181
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
182
|
+
return this.ctx.storage.transaction(async tx => {
|
|
183
|
+
const thread = await loadThread(tx, this.ctx, ref);
|
|
184
|
+
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
|
|
185
|
+
const members = (await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value);
|
|
186
|
+
return { thread, members };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async member(auth: Auth, ref: ThreadRef, peer: string, patch: Partial<Member>, mutation: Mutation): Promise<Member> {
|
|
191
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
192
|
+
requireThreadRef(ref);
|
|
193
|
+
if (typeof peer !== 'string' || peer.length === 0) fail('invalid_request', 'A membership names its peer.');
|
|
194
|
+
const isOwner = auth.actor === ref.owner;
|
|
195
|
+
if (!isOwner && auth.actor !== peer) fail('forbidden', 'Only the owner manages other peers.');
|
|
196
|
+
if (patch.revision !== undefined) fail('invalid_request', 'The revision is issued by the owner.');
|
|
197
|
+
if (!isOwner && (patch.access !== undefined || patch.read_scope !== undefined || (patch.state !== undefined && patch.state !== 'revoked' && patch.state !== 'active'))) {
|
|
198
|
+
fail('forbidden', 'A peer may only join or withdraw itself.');
|
|
199
|
+
}
|
|
200
|
+
const body = { ref, peer, patch };
|
|
201
|
+
return this.ctx.mutate(auth, `thread.member/${ref.owner}/${ref.id}/${peer}`, mutation, body, async tx => {
|
|
202
|
+
const thread = await loadThread(tx, this.ctx, ref);
|
|
203
|
+
const current = await tx.get<Member>(key('member', ref.owner, ref.id, peer));
|
|
204
|
+
let next: Member;
|
|
205
|
+
if (!isOwner) {
|
|
206
|
+
if (current === undefined) {
|
|
207
|
+
if (patch.state === 'revoked') fail('not_found', 'No membership record to withdraw.');
|
|
208
|
+
if (thread.state === 'closed') fail('thread_closed', 'A closed thread does not accept joins.');
|
|
209
|
+
const admitted = (await this.ctx.options.plugins.mayAdmit?.(auth.actor, ref, admissionView(tx))) === true;
|
|
210
|
+
next = { peer, state: admitted ? 'active' : 'requested', access: [...DEFAULT_ACCESS], read_scope: {}, revision: this.ctx.id() };
|
|
211
|
+
} else if (patch.state === 'revoked') {
|
|
212
|
+
if (current.state !== 'active' && current.state !== 'requested') fail('conflict', 'This membership is already stopped.');
|
|
213
|
+
next = { ...current, state: 'revoked', access: [], revision: this.ctx.id() };
|
|
214
|
+
} else {
|
|
215
|
+
fail('conflict', 'This peer already holds a membership record; only the owner changes it.');
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
const state = patch.state ?? 'active';
|
|
219
|
+
if (state !== 'requested' && state !== 'active' && state !== 'revoked' && state !== 'denied') fail('invalid_request', 'Unknown member state.');
|
|
220
|
+
const stopping = state === 'revoked' || state === 'denied';
|
|
221
|
+
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.ctx.id() };
|
|
223
|
+
}
|
|
224
|
+
await tx.put(key('member', ref.owner, ref.id, peer), next);
|
|
225
|
+
if (next.state === 'active' && current?.state !== 'active' && peer !== ref.owner) await issueGrant(tx, ref, peer, next.revision);
|
|
226
|
+
await recordEvent(tx, thread, 'membership', JSON.parse(JSON.stringify(next)) as Json);
|
|
227
|
+
return next;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async members(auth: Auth, ref: ThreadRef, options: PageOptions = {}): Promise<Page<Member>> {
|
|
232
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
233
|
+
return this.ctx.storage.transaction(async tx => {
|
|
234
|
+
await loadThread(tx, this.ctx, ref);
|
|
235
|
+
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'The caller does not hold read access to this thread.');
|
|
236
|
+
const members = sortedBy((await tx.scan<Member>(key('member', ref.owner, ref.id) + '/')).map(row => row.value), member => member.peer);
|
|
237
|
+
return page(members, options, { kind: 'members', ref }, member => member.peer);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async defaultThread(auth: Auth): Promise<ThreadRef> {
|
|
242
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
243
|
+
this.ctx.owner(auth.actor);
|
|
244
|
+
return this.ctx.storage.transaction(async tx => requireValue(await tx.get<ThreadRef>(key('default', auth.actor))));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Owner-side notice outbox: every grant notice this node issued that has no ack yet.
|
|
249
|
+
* Only the owner calling as ctx.node may read this list; each item carries recipient
|
|
250
|
+
* so the client knows where to POST the full notice.
|
|
251
|
+
*/
|
|
252
|
+
async notices(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
|
|
253
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
254
|
+
this.ctx.owner(auth.actor);
|
|
255
|
+
return this.ctx.storage.transaction(async tx => {
|
|
256
|
+
const held = sortedBy((await tx.scan<Grant>(key('notice') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
|
|
257
|
+
return page(held, options, { kind: 'notices', owner: auth.actor }, grant => grant.id);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Recipient view: the grants this node accepted from owners, until it confirms each one.
|
|
263
|
+
* A missed push is fetched over HTTP and processed with acceptGrant; this list is what
|
|
264
|
+
* the confirmation pass walks to close the owner's notice.
|
|
265
|
+
*/
|
|
266
|
+
async grants(auth: Auth, options: PageOptions = {}): Promise<Page<Grant>> {
|
|
267
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
268
|
+
return this.ctx.storage.transaction(async tx => {
|
|
269
|
+
const held = sortedBy((await tx.scan<Grant>(key('grant') + '/')).map(row => row.value).filter(grant => !grant.acked), grant => grant.id);
|
|
270
|
+
return page(held, options, { kind: 'grants', recipient: auth.actor }, grant => grant.id);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Recipient side: records that the owner confirmed this grant, so it is not confirmed twice. */
|
|
275
|
+
async ackGrantLocally(auth: Auth, ref: ThreadRef, id: string): Promise<void> {
|
|
276
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
277
|
+
await this.ctx.storage.transaction(async tx => {
|
|
278
|
+
const slot = key('grant', ref.owner, id);
|
|
279
|
+
const stored = await tx.get<Grant>(slot);
|
|
280
|
+
if (stored !== undefined && !stored.acked) await tx.put(slot, { ...stored, acked: true });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Recipient side of grant acceptance: an owner pushes us a notice we received during
|
|
286
|
+
* a prior join. We store both the notice and the known-owner record keyed owner+threadid.
|
|
287
|
+
* Caller is the owner the notice names, notice is addressed to our node, thread stays on owner.
|
|
288
|
+
*/
|
|
289
|
+
async acceptGrant(auth: Auth, grant: Grant): Promise<Grant> {
|
|
290
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
291
|
+
if (grant == null || typeof grant !== 'object' || typeof grant.id !== 'string' || grant.id.length === 0 || typeof grant.revision !== 'string' || grant.revision.length === 0) {
|
|
292
|
+
fail('invalid_request', 'Expected a grant notice with an id and a revision.');
|
|
293
|
+
}
|
|
294
|
+
requireThreadRef(grant.thread_ref);
|
|
295
|
+
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.ctx.node) fail('forbidden', 'The grant notice is addressed to another node.');
|
|
297
|
+
const ref: ThreadRef = { owner: grant.thread_ref.owner, id: grant.thread_ref.id };
|
|
298
|
+
return this.ctx.storage.transaction(async tx => {
|
|
299
|
+
const slot = key('grant', ref.owner, grant.id);
|
|
300
|
+
const stored = await tx.get<Grant>(slot);
|
|
301
|
+
if (stored !== undefined && (stored.recipient !== this.ctx.node || stored.revision !== grant.revision || stored.thread_ref.id !== ref.id)) {
|
|
302
|
+
fail('id_conflict', 'The grant id is bound to another notice.');
|
|
303
|
+
}
|
|
304
|
+
const known: Known = { owner: ref.owner, thread_ref: ref, revision: grant.revision };
|
|
305
|
+
await tx.put(key('known', ref.owner, ref.id), known);
|
|
306
|
+
if (stored !== undefined) return stored;
|
|
307
|
+
const next: Grant = { id: grant.id, recipient: this.ctx.node, thread_ref: ref, revision: grant.revision, acked: false };
|
|
308
|
+
await tx.put(slot, next);
|
|
309
|
+
return next;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Acknowledge a notice on the owner node: owner receives our confirmation, updates
|
|
315
|
+
* the owner-issued notice to acked:true, stops retries. Caller must be recipient of the notice.
|
|
316
|
+
*/
|
|
317
|
+
async ackGrant(auth: Auth, id: string, mutation: Mutation): Promise<Grant> {
|
|
318
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
319
|
+
if (typeof id !== 'string' || id.length === 0) fail('invalid_request', 'A grant id is required.');
|
|
320
|
+
const body = { id };
|
|
321
|
+
return this.ctx.mutate(auth, `thread.grant.ack/${id}`, mutation, body, async tx => {
|
|
322
|
+
const grant = requireValue(await tx.get<Grant>(key('notice', id)));
|
|
323
|
+
if (grant.recipient !== auth.actor) fail('forbidden', 'The grant notice is addressed to another recipient.');
|
|
324
|
+
if (grant.acked) return grant;
|
|
325
|
+
const acked: Grant = { ...grant, acked: true };
|
|
326
|
+
await tx.put(key('notice', id), acked);
|
|
327
|
+
return acked;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async permits(auth: Auth, permit: ReplyPermit, mutation: Mutation): Promise<ReplyPermit> {
|
|
332
|
+
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
333
|
+
const original: MessageRef | undefined = permit?.message_ref;
|
|
334
|
+
if (typeof original?.sender !== 'string' || !original.sender || typeof original.id !== 'string' || !original.id) fail('invalid_request', 'A permit binds a complete MessageRef.');
|
|
335
|
+
if (original.sender !== auth.actor) fail('forbidden', 'Only the original message sender may issue a reply permit.');
|
|
336
|
+
if (typeof permit.grantee !== 'string' || !permit.grantee || permit.grantee === auth.actor) fail('invalid_request', 'A permit names one grantee other than its issuer.');
|
|
337
|
+
requireThreadRef(permit.landing);
|
|
338
|
+
this.ctx.owner(permit.landing.owner);
|
|
339
|
+
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.ctx.now()) fail('invalid_request', 'A permit needs a future expiry.');
|
|
341
|
+
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.ctx.mutate(auth, `thread.permit/${original.sender}/${original.id}/${permit.grantee}`, mutation, body, async tx => {
|
|
343
|
+
const thread = await loadThread(tx, this.ctx, permit.landing);
|
|
344
|
+
if (thread.state === 'closed') fail('thread_closed', 'A permit cannot land on a closed thread.');
|
|
345
|
+
const stored: ReplyPermit = { ...body, revoked: permit.revoked === true };
|
|
346
|
+
await tx.put(key('permit', original.sender, original.id, permit.grantee), stored);
|
|
347
|
+
return stored;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { OnlyneError } from './core.js';
|
|
2
|
+
import type { ErrorCode, Failure, NodeId, Target, Transport, TrustEntry } from './types.js';
|
|
3
|
+
import type { TrustService } from './keys.js';
|
|
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 agent cannot pair the nodes — only the node's holder can,
|
|
68
|
+
* through the host surface — and the runtime re-sends the record that stays open, so the code
|
|
69
|
+
* is `unavailable` and the message keeps that retry apart from an agent repeating the send.
|
|
70
|
+
* The failure names the responsible holder and the act; the host's `guidance` adds the menu
|
|
71
|
+
* entry, so every trust case ends at one concrete surface instead of a search.
|
|
72
|
+
*/
|
|
73
|
+
function missingTrust(
|
|
74
|
+
self: NodeId | null, target: NodeId, peer: TrustEntry | undefined,
|
|
75
|
+
guidance: SigningTransportOptions['guidance'],
|
|
76
|
+
): OnlyneError {
|
|
77
|
+
const here = self ?? 'this node';
|
|
78
|
+
const retry = 'The runtime re-sends the open record once the pairing exists; do not re-send it yourself.';
|
|
79
|
+
const reason: TrustFailure['reason'] = peer === undefined ? 'no_record' : peer.state === 'revoked' ? 'revoked' : 'pending';
|
|
80
|
+
const required_action: TrustFailure['required_action'] = reason === 'pending' ? 'confirm_exchange' : reason === 'revoked' ? 'restore_trust' : 'request_exchange';
|
|
81
|
+
// A pending row says which holder still owes the act: ours when the peer asked, theirs when we did.
|
|
82
|
+
const responsible_node = reason === 'pending' && peer?.local_approved === true ? target : self;
|
|
83
|
+
const details = { self, peer: target, reason, responsible_node, required_action };
|
|
84
|
+
const guide = guidance?.(details);
|
|
85
|
+
const surface = guide === undefined ? '' : `: ${guide.entry.join(' → ')}`;
|
|
86
|
+
const failure = guide === undefined ? details : { ...details, entry: guide.entry };
|
|
87
|
+
if (reason === 'no_record') {
|
|
88
|
+
return new OnlyneError('unavailable',
|
|
89
|
+
`${here} holds no trust record for ${target}, so this request cannot be signed. This agent cannot grant trust; the holder of ${here} completes the pairing through this node's Onlyne surface${surface}. ${retry}`,
|
|
90
|
+
failure);
|
|
91
|
+
}
|
|
92
|
+
if (reason === 'revoked') {
|
|
93
|
+
return new OnlyneError('unavailable',
|
|
94
|
+
`${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}`,
|
|
95
|
+
failure);
|
|
96
|
+
}
|
|
97
|
+
const message = peer!.local_approved
|
|
98
|
+
? `${target} has not answered the exchange ${here} requested, so this request cannot be signed yet. This agent cannot complete the pairing; the holder of ${target} confirms it through that node's Onlyne surface${surface}. ${retry}`
|
|
99
|
+
: `${target} requested an exchange with ${here} and this node has not approved it, so this request cannot be signed yet. This agent cannot approve it; the holder of ${here} confirms the fingerprint through this node's Onlyne surface${surface}. ${retry}`;
|
|
100
|
+
return new OnlyneError('unavailable', message, failure);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class FetchTransport implements Transport {
|
|
104
|
+
private readonly resolve: (node: NodeId) => Promise<Target>;
|
|
105
|
+
private readonly fetcher: typeof fetch;
|
|
106
|
+
private readonly signing: SigningTransportOptions | undefined;
|
|
107
|
+
|
|
108
|
+
constructor(resolve: (node: NodeId) => Promise<Target>, fetcher: typeof fetch = fetch, signing?: SigningTransportOptions) {
|
|
109
|
+
this.resolve = resolve;
|
|
110
|
+
this.fetcher = fetcher;
|
|
111
|
+
this.signing = signing;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async request<T>(target: NodeId, method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
|
115
|
+
const endpoint = await this.resolve(target);
|
|
116
|
+
if (endpoint.node_id !== target) {
|
|
117
|
+
throw new OnlyneError('unavailable', 'The resolver returned a target for another identity.');
|
|
118
|
+
}
|
|
119
|
+
const base = serviceBase(endpoint.base_url);
|
|
120
|
+
// The route is relative to base_url, which already carries the service
|
|
121
|
+
// prefix; a leading slash would silently drop that prefix.
|
|
122
|
+
const url = new URL('./' + path.replace(/^\/+/, ''), base.pathname.endsWith('/') ? base : base.href + '/');
|
|
123
|
+
if (url.origin !== base.origin) {
|
|
124
|
+
throw new OnlyneError('invalid_request', 'The request path leaves the endpoint origin.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const headers = new Headers(endpoint.headers);
|
|
128
|
+
const serialized = body === undefined ? '' : JSON.stringify(body);
|
|
129
|
+
if (body !== undefined) {
|
|
130
|
+
headers.set('Content-Type', 'application/json');
|
|
131
|
+
// A server rejects the whole request when this header contradicts the
|
|
132
|
+
// body, so the key travels as a header only from the top-level field the
|
|
133
|
+
// body already carries; the body keeps its own copy.
|
|
134
|
+
if (typeof body === 'object' && body !== null && 'idempotency_key' in body && typeof body.idempotency_key === 'string' && body.idempotency_key !== '') {
|
|
135
|
+
headers.set('Idempotency-Key', body.idempotency_key);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (this.signing !== undefined) {
|
|
139
|
+
const peer = await this.signing.trust.entry(target);
|
|
140
|
+
// No trusted key for this peer: the request cannot be signed, and sending it unsigned
|
|
141
|
+
// would be refused anyway. Report a retryable outage so the record stays open while the
|
|
142
|
+
// node holder completes the pairing, instead of recording a permanent refusal. The
|
|
143
|
+
// resolver supplies `Onlyne-Actor`, so the outbound request already names this node.
|
|
144
|
+
if (peer?.state !== 'trusted') {
|
|
145
|
+
throw missingTrust(headers.get('Onlyne-Actor'), target, peer, this.signing.guidance);
|
|
146
|
+
}
|
|
147
|
+
{
|
|
148
|
+
const signer = new Request(url.href, { method, headers });
|
|
149
|
+
await this.signing.trust.signRequest(signer, target, serialized);
|
|
150
|
+
for (const [name, value] of signer.headers) headers.set(name, value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let response: Response;
|
|
155
|
+
const request = new Request(url.href, { method, headers });
|
|
156
|
+
try {
|
|
157
|
+
response = await this.fetcher(url, {
|
|
158
|
+
method,
|
|
159
|
+
headers,
|
|
160
|
+
body: body === undefined ? undefined : serialized,
|
|
161
|
+
signal,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// A consumer's cancellation is not a reachability fault; the caller sees
|
|
165
|
+
// its own abort reason instead of a retryable outage.
|
|
166
|
+
if (signal?.aborted) throw signal.reason;
|
|
167
|
+
throw new OnlyneError('unavailable', 'The target could not be reached.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let text: string;
|
|
171
|
+
try {
|
|
172
|
+
text = await response.text();
|
|
173
|
+
} catch {
|
|
174
|
+
throw new OnlyneError('unavailable', 'The response body could not be read.');
|
|
175
|
+
}
|
|
176
|
+
let parsed: unknown;
|
|
177
|
+
try {
|
|
178
|
+
parsed = text === '' ? undefined : JSON.parse(text);
|
|
179
|
+
} catch {
|
|
180
|
+
parsed = undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (response.ok) {
|
|
184
|
+
if (parsed === undefined) throw new OnlyneError('unavailable', 'The target returned a malformed response.');
|
|
185
|
+
// A signed request is only ever sent to a trusted peer, so its answer must prove that
|
|
186
|
+
// identity; `verifyResponse` fails when the row is missing, untrusted or the signature
|
|
187
|
+
// does not cover this request's nonce.
|
|
188
|
+
if (this.signing !== undefined) await this.signing.trust.verifyResponse(target, request, response, text);
|
|
189
|
+
return parsed as T;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const record = failureRecord(parsed);
|
|
193
|
+
// A signed request is answered by a signed response even when it fails; an unsigned or
|
|
194
|
+
// forged error must not be read as the peer's refusal.
|
|
195
|
+
if (this.signing !== undefined) await this.signing.trust.verifyResponse(target, request, response, text);
|
|
196
|
+
if (isFailure(record)) {
|
|
197
|
+
const rejection = new OnlyneError(record.code as ErrorCode, record.message);
|
|
198
|
+
if (typeof record.retryable === 'boolean') rejection.error.retryable = record.retryable;
|
|
199
|
+
if (record.details !== undefined) rejection.error.details = record.details;
|
|
200
|
+
throw rejection;
|
|
201
|
+
}
|
|
202
|
+
throw new OnlyneError(response.status >= 500 ? 'unavailable' : 'internal', `The target answered HTTP ${response.status} without a failure record.`);
|
|
203
|
+
}
|
|
204
|
+
}
|