@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/discovery.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { OnlyneError, fail, key, page, type Context } from './core.
|
|
2
|
-
import { membershipRecord, validateDomainId } from './domains.
|
|
1
|
+
import { OnlyneError, fail, key, page, type Context } from './core.ts';
|
|
2
|
+
import { membershipRecord, validateDomainId } from './domains.ts';
|
|
3
3
|
import type {
|
|
4
4
|
DiscoveryCandidate, DiscoveryRecord, DomainId, Failure, NodeId, Page, PageOptions, ServiceDescription, Target,
|
|
5
|
-
} from './types.
|
|
5
|
+
} from './types.ts';
|
|
6
6
|
|
|
7
7
|
/** How long candidate evidence stays current. An expired record is history, not a live address. */
|
|
8
8
|
const FRESH_MS = 120_000;
|
|
@@ -25,12 +25,12 @@ function loopback(url: URL): boolean {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** True for an address another host could use. Loopback, wildcard and unscoped link-local stay home. */
|
|
28
|
-
function exchangeable(value: string): boolean {
|
|
28
|
+
function exchangeable(value: string, allowLoopback = false): boolean {
|
|
29
29
|
let url: URL;
|
|
30
30
|
try { url = new URL(value); } catch { return false; }
|
|
31
31
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
32
32
|
const host = hostOf(url);
|
|
33
|
-
if (loopback(url) || host === '0.0.0.0' || host === '::') return false;
|
|
33
|
+
if ((!allowLoopback && loopback(url)) || host === '0.0.0.0' || host === '::') return false;
|
|
34
34
|
if (/^169\.254\./.test(host)) return false;
|
|
35
35
|
return !(/^fe80:/.test(host) && !host.includes('%'));
|
|
36
36
|
}
|
|
@@ -101,11 +101,19 @@ function published(value: unknown): ServiceDescription {
|
|
|
101
101
|
export class DiscoveryService {
|
|
102
102
|
/** Directly verified identities in this process; lets admission policies answer without nested storage work. */
|
|
103
103
|
readonly #known = new Set<NodeId>();
|
|
104
|
-
|
|
104
|
+
readonly #ctx: Context;
|
|
105
|
+
readonly #fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
106
|
+
|
|
107
|
+
constructor(ctx: Context, fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch) {
|
|
108
|
+
this.#ctx = ctx;
|
|
109
|
+
this.#fetcher = fetcher;
|
|
110
|
+
}
|
|
105
111
|
|
|
106
112
|
/** Whether this process has directly verified this identity at least once. */
|
|
107
113
|
known(node_id: NodeId): boolean { return this.#known.has(node_id); }
|
|
108
114
|
|
|
115
|
+
get #allowLoopback(): boolean { return Boolean(this.#ctx.options.plugins.allow_loopback); }
|
|
116
|
+
|
|
109
117
|
/**
|
|
110
118
|
* Reads `base_url`'s service description and records what it declares. A failed observation
|
|
111
119
|
* keeps the last verified identity of the address and stores the failure beside it.
|
|
@@ -114,7 +122,7 @@ export class DiscoveryService {
|
|
|
114
122
|
if (source.length === 0) fail('invalid_request', 'A discovery record needs a source.');
|
|
115
123
|
const url = address(base_url);
|
|
116
124
|
if (url.protocol === 'http:' && !loopback(url)) fail('unavailable', 'Plain HTTP is limited to loopback; cross-host candidates must use HTTPS.');
|
|
117
|
-
const at = this
|
|
125
|
+
const at = this.#ctx.now();
|
|
118
126
|
let description: ServiceDescription;
|
|
119
127
|
try {
|
|
120
128
|
description = published(await this.#read(url, signal));
|
|
@@ -138,25 +146,25 @@ export class DiscoveryService {
|
|
|
138
146
|
verified_at: iso(at),
|
|
139
147
|
membership: description.membership,
|
|
140
148
|
};
|
|
141
|
-
await this
|
|
149
|
+
await this.#ctx.storage.transaction(async tx => { await tx.put(key(PREFIX, record.node_id, base_url), record); });
|
|
142
150
|
this.#known.add(record.node_id);
|
|
143
151
|
return record;
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
/** The public candidate exchange: verified, fresh and reachable from somewhere other than this host. */
|
|
147
155
|
async list(options: PageOptions = {}): Promise<Page<DiscoveryCandidate>> {
|
|
148
|
-
const at = this
|
|
156
|
+
const at = this.#ctx.now();
|
|
149
157
|
const candidates = (await this.listRecords())
|
|
150
158
|
.filter(record => record.verified_at !== undefined && record.instance_id !== ''
|
|
151
|
-
&& exchangeable(record.base_url) && Date.parse(record.expires_at) > at)
|
|
159
|
+
&& exchangeable(record.base_url, this.#allowLoopback) && Date.parse(record.expires_at) > at)
|
|
152
160
|
.map(record => ({ node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url, last_seen: record.last_seen }))
|
|
153
161
|
.sort((a, b) => order(position(a), position(b)));
|
|
154
|
-
return page(candidates, options, { kind: 'discovery', node: this
|
|
162
|
+
return page(candidates, options, { kind: 'discovery', node: this.#ctx.node }, position);
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
/** Every observation this node holds, expired and failed ones included. */
|
|
158
166
|
async listRecords(): Promise<DiscoveryRecord[]> {
|
|
159
|
-
const rows = await this
|
|
167
|
+
const rows = await this.#ctx.storage.transaction(tx => tx.scan<DiscoveryRecord>(`${PREFIX}/`));
|
|
160
168
|
return rows.map(row => row.value).sort((a, b) => order(position(a), position(b)));
|
|
161
169
|
}
|
|
162
170
|
|
|
@@ -188,11 +196,11 @@ export class DiscoveryService {
|
|
|
188
196
|
/** Verified, fresh candidates that declare exactly this region. */
|
|
189
197
|
async candidates(domain_id: DomainId): Promise<NodeId[]> {
|
|
190
198
|
validateDomainId(domain_id);
|
|
191
|
-
const at = this
|
|
199
|
+
const at = this.#ctx.now();
|
|
192
200
|
const nodes = new Set<NodeId>();
|
|
193
201
|
for (const record of await this.listRecords()) {
|
|
194
202
|
if (record.verified_at === undefined || record.instance_id === '') continue;
|
|
195
|
-
if (Date.parse(record.expires_at) <= at || !exchangeable(record.base_url)) continue;
|
|
203
|
+
if (Date.parse(record.expires_at) <= at || !exchangeable(record.base_url, this.#allowLoopback)) continue;
|
|
196
204
|
const membership = record.membership;
|
|
197
205
|
if (membership === null || membership === undefined || membership.state !== 'active' || membership.domain_id !== domain_id) continue;
|
|
198
206
|
nodes.add(record.node_id);
|
|
@@ -217,12 +225,12 @@ export class DiscoveryService {
|
|
|
217
225
|
controller.signal.addEventListener('abort', () => gate.reject(controller.signal.reason), { once: true });
|
|
218
226
|
const timeout = setTimeout(
|
|
219
227
|
() => controller.abort(new OnlyneError('unavailable', 'The candidate description lookup timed out.')),
|
|
220
|
-
this
|
|
228
|
+
this.#ctx.options.wait_ms ?? 30_000,
|
|
221
229
|
);
|
|
222
230
|
let response: Response;
|
|
223
231
|
try {
|
|
224
232
|
response = await Promise.race([
|
|
225
|
-
this
|
|
233
|
+
this.#fetcher(target, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
|
|
226
234
|
gate.promise,
|
|
227
235
|
]);
|
|
228
236
|
} catch (error) {
|
|
@@ -253,10 +261,10 @@ export class DiscoveryService {
|
|
|
253
261
|
* candidate that has to be rediscovered, not a permanent entry.
|
|
254
262
|
*/
|
|
255
263
|
async #failed(base_url: string, source: string, at: number, error: Failure): Promise<void> {
|
|
256
|
-
await this
|
|
264
|
+
await this.#ctx.storage.transaction(async tx => {
|
|
257
265
|
const rows = (await tx.scan<DiscoveryRecord>(`${PREFIX}/`)).filter(row => row.value.base_url === base_url);
|
|
258
266
|
for (const row of rows) {
|
|
259
|
-
if (row.value.verified_at !== undefined
|
|
267
|
+
if (row.value.verified_at !== undefined) {
|
|
260
268
|
await tx.put(row.key, { ...row.value, source, received_at: iso(at), error });
|
|
261
269
|
continue;
|
|
262
270
|
}
|
package/src/domains.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { fail, key, OnlyneError, page, requireValue, type Context } from './core.
|
|
2
|
-
import { serviceBase } from './transport.
|
|
3
|
-
import type { Auth, DomainDiscovery, DomainMembership, DomainMutation, Mutation, PageOptions, Transaction } from './types.
|
|
1
|
+
import { fail, key, OnlyneError, page, requireValue, type Context } from './core.ts';
|
|
2
|
+
import { serviceBase } from './transport.ts';
|
|
3
|
+
import type { Auth, DomainDiscovery, DomainMembership, DomainMutation, Mutation, PageOptions, Transaction } from './types.ts';
|
|
4
4
|
|
|
5
5
|
export function validateDomainId(value: unknown): string {
|
|
6
6
|
if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$/.test(value)) {
|
|
@@ -56,10 +56,13 @@ async function activeMembership(tx: Transaction, node: string): Promise<DomainMe
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export class DomainService {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
) {
|
|
59
|
+
readonly #ctx: Context;
|
|
60
|
+
readonly #fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
61
|
+
|
|
62
|
+
constructor(ctx: Context, fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch) {
|
|
63
|
+
this.#ctx = ctx;
|
|
64
|
+
this.#fetcher = fetcher;
|
|
65
|
+
}
|
|
63
66
|
|
|
64
67
|
private authenticate(auth: Auth): void {
|
|
65
68
|
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
@@ -67,19 +70,19 @@ export class DomainService {
|
|
|
67
70
|
|
|
68
71
|
private self(auth: Auth): void {
|
|
69
72
|
this.authenticate(auth);
|
|
70
|
-
if (auth.actor !== this
|
|
73
|
+
if (auth.actor !== this.#ctx.node) fail('forbidden', 'A node may only change its own domain membership.');
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
async create(auth: Auth, mutation: Mutation): Promise<DomainMembership> {
|
|
74
77
|
this.self(auth);
|
|
75
|
-
return this
|
|
76
|
-
if (await activeMembership(tx, this
|
|
78
|
+
return this.#ctx.mutate(auth, 'domain.create', mutation, {}, async tx => {
|
|
79
|
+
if (await activeMembership(tx, this.#ctx.node)) {
|
|
77
80
|
fail('conflict', 'This node already belongs to a domain; leave it before creating another.');
|
|
78
81
|
}
|
|
79
|
-
const domain_id = validateDomainId(`urn:onlyne:region:${this
|
|
82
|
+
const domain_id = validateDomainId(`urn:onlyne:region:${this.#ctx.id()}`);
|
|
80
83
|
const slot = key('domain', domain_id);
|
|
81
84
|
if (await tx.get(slot)) fail('id_conflict', 'The domain identifier is already recorded here.');
|
|
82
|
-
const record: DomainMembership = { domain_id, node_id: this
|
|
85
|
+
const record: DomainMembership = { domain_id, node_id: this.#ctx.node, state: 'active', revision: this.#ctx.id() };
|
|
83
86
|
await tx.put(slot, record);
|
|
84
87
|
return record;
|
|
85
88
|
});
|
|
@@ -96,12 +99,12 @@ export class DomainService {
|
|
|
96
99
|
private async change(auth: Auth, id: string, state: DomainMembership['state'], mutation: DomainMutation): Promise<DomainMembership> {
|
|
97
100
|
this.self(auth);
|
|
98
101
|
validateDomainId(id);
|
|
99
|
-
return this
|
|
102
|
+
return this.#ctx.mutate(auth, `domain.${state}/${id}`, mutation, { expected_revision: mutation.expected_revision ?? null }, async tx => {
|
|
100
103
|
const slot = key('domain', id);
|
|
101
104
|
const current = await tx.get<DomainMembership>(slot);
|
|
102
105
|
if (!current && state === 'left') fail('not_found', 'No membership is recorded for this domain.');
|
|
103
106
|
// A node belongs to one domain. Cross-domain reach is a property of threads and ingress, not of membership.
|
|
104
|
-
const active = await activeMembership(tx, this
|
|
107
|
+
const active = await activeMembership(tx, this.#ctx.node);
|
|
105
108
|
if (state === 'active' && active !== undefined && active.domain_id !== id) {
|
|
106
109
|
fail('conflict', 'This node already belongs to another domain; leave it first.');
|
|
107
110
|
}
|
|
@@ -109,9 +112,9 @@ export class DomainService {
|
|
|
109
112
|
fail('revision_conflict', 'The local domain membership changed.', current ? { current_revision: current.revision } : undefined);
|
|
110
113
|
}
|
|
111
114
|
if (current?.state === state) return current;
|
|
112
|
-
const revision = this
|
|
115
|
+
const revision = this.#ctx.id();
|
|
113
116
|
if (revision === current?.revision) fail('internal', 'The revision source reused the current membership revision.');
|
|
114
|
-
const record: DomainMembership = { domain_id: id, node_id: this
|
|
117
|
+
const record: DomainMembership = { domain_id: id, node_id: this.#ctx.node, state, revision };
|
|
115
118
|
await tx.put(slot, record);
|
|
116
119
|
return record;
|
|
117
120
|
});
|
|
@@ -119,34 +122,34 @@ export class DomainService {
|
|
|
119
122
|
|
|
120
123
|
async list(auth: Auth, options: PageOptions = {}) {
|
|
121
124
|
this.authenticate(auth);
|
|
122
|
-
return this
|
|
123
|
-
const current = await activeMembership(tx, this
|
|
125
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
126
|
+
const current = await activeMembership(tx, this.#ctx.node);
|
|
124
127
|
const records = current === undefined ? [] : [current];
|
|
125
128
|
records.sort((a, b) => a.domain_id < b.domain_id ? -1 : a.domain_id > b.domain_id ? 1 : 0);
|
|
126
|
-
return page(records, options, { kind: 'domains', node: this
|
|
129
|
+
return page(records, options, { kind: 'domains', node: this.#ctx.node, actor: auth.actor }, row => row.domain_id);
|
|
127
130
|
});
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
async describe(auth: Auth, id: string): Promise<DomainMembership> {
|
|
131
134
|
this.authenticate(auth);
|
|
132
135
|
validateDomainId(id);
|
|
133
|
-
return this
|
|
136
|
+
return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<DomainMembership>(key('domain', id))));
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
async discover(auth: Auth, id: string, signal?: AbortSignal): Promise<DomainDiscovery> {
|
|
137
140
|
this.authenticate(auth);
|
|
138
141
|
validateDomainId(id);
|
|
139
142
|
if ((await this.describe(auth, id)).state !== 'active') fail('not_a_member', 'This node has left the domain.');
|
|
140
|
-
const candidates = this
|
|
143
|
+
const candidates = this.#ctx.options.plugins.domainCandidates;
|
|
141
144
|
if (!candidates) fail('unsupported_delivery', 'The host provides no domain discovery source.');
|
|
142
145
|
signal?.throwIfAborted();
|
|
143
|
-
const nodes = [...new Set([this
|
|
146
|
+
const nodes = [...new Set([this.#ctx.node, ...await candidates(id)])].sort();
|
|
144
147
|
const members: DomainMembership[] = [];
|
|
145
148
|
const unavailable: string[] = [];
|
|
146
149
|
for (const node of nodes) {
|
|
147
150
|
signal?.throwIfAborted();
|
|
148
151
|
if (typeof node !== 'string' || node.length === 0) fail('invalid_request', 'Domain candidates must be node identities.');
|
|
149
|
-
if (node === this
|
|
152
|
+
if (node === this.#ctx.node) {
|
|
150
153
|
const current = await this.describe(auth, id);
|
|
151
154
|
if (current.state === 'active') members.push(current);
|
|
152
155
|
continue;
|
|
@@ -168,7 +171,7 @@ export class DomainService {
|
|
|
168
171
|
private async query(node: string, signal?: AbortSignal): Promise<unknown> {
|
|
169
172
|
// A candidate's service description is public: it is read before any key exchange, so
|
|
170
173
|
// it never goes through the signing transport (RFC 0011 §5, RFC 0017 §4).
|
|
171
|
-
const target = await this
|
|
174
|
+
const target = await this.#ctx.options.plugins.resolve(node);
|
|
172
175
|
if (target.node_id !== node) fail('unavailable', 'The resolver returned a target for another identity.');
|
|
173
176
|
const base = serviceBase(target.base_url);
|
|
174
177
|
const url = new URL('./description', base.href.endsWith('/') ? base.href : `${base.href}/`);
|
|
@@ -181,10 +184,10 @@ export class DomainService {
|
|
|
181
184
|
controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
|
|
182
185
|
if (controller.signal.aborted) reject(controller.signal.reason);
|
|
183
186
|
});
|
|
184
|
-
timer = setTimeout(() => controller.abort(new OnlyneError('unavailable', 'Service description lookup timed out.')), this
|
|
187
|
+
timer = setTimeout(() => controller.abort(new OnlyneError('unavailable', 'Service description lookup timed out.')), this.#ctx.options.wait_ms ?? 30_000);
|
|
185
188
|
try {
|
|
186
189
|
const response = await Promise.race([
|
|
187
|
-
this
|
|
190
|
+
this.#fetcher(url, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
|
|
188
191
|
aborted,
|
|
189
192
|
]);
|
|
190
193
|
const text = await response.text().catch(() => '');
|
package/src/history.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import type { Auth, Conversation, Entry, HistoryMessage, HistoryQuery, LocalAttention, MessageRef, Outbound, Page, ThreadRef, Transaction } from './types.
|
|
2
|
-
import { fail, key, page, same, type Context } from './core.
|
|
1
|
+
import type { AttentionSettlement, Auth, Conversation, Entry, HistoryMessage, HistoryQuery, LocalAttention, MessageRef, Outbound, Page, ThreadRef, Transaction } from './types.ts';
|
|
2
|
+
import { fail, key, page, same, type Context } from './core.ts';
|
|
3
3
|
|
|
4
4
|
const identity = (ref: MessageRef): string => key(ref.sender, ref.id);
|
|
5
5
|
const threadIdentity = (ref: ThreadRef): string => key(ref.owner, ref.id);
|
|
6
6
|
|
|
7
7
|
/** A read-only projection of the records this node already holds. It performs no remote reads. */
|
|
8
8
|
export class HistoryService {
|
|
9
|
-
|
|
9
|
+
readonly #ctx: Context;
|
|
10
|
+
|
|
11
|
+
constructor(ctx: Context) {
|
|
12
|
+
this.#ctx = ctx;
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
async list(auth: Auth, query: HistoryQuery = {}): Promise<Page<Conversation>> {
|
|
12
16
|
this.authorize(auth);
|
|
13
|
-
return this
|
|
17
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
14
18
|
const conversations = await this.collect(tx);
|
|
15
19
|
const selected = conversations.filter(conversation =>
|
|
16
20
|
(query.thread === undefined || conversation.thread_refs.some(ref => same(ref, query.thread)))
|
|
@@ -22,7 +26,7 @@ export class HistoryService {
|
|
|
22
26
|
|
|
23
27
|
async conversation(auth: Auth, message: MessageRef): Promise<Conversation> {
|
|
24
28
|
this.authorize(auth);
|
|
25
|
-
return this
|
|
29
|
+
return this.#ctx.storage.transaction(async tx => {
|
|
26
30
|
const found = (await this.collect(tx)).find(row => same(row.id, message) || row.messages.some(item => same(item.message_ref, message)));
|
|
27
31
|
if (found === undefined) fail('not_found', 'This node holds no conversation for that message.');
|
|
28
32
|
return found;
|
|
@@ -37,26 +41,31 @@ export class HistoryService {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
private authorize(auth: Auth): void {
|
|
40
|
-
if (auth.actor !== this
|
|
44
|
+
if (auth.actor !== this.#ctx.node) fail('forbidden', 'Conversation history belongs to this node.');
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
private async collect(tx: Transaction): Promise<Conversation[]> {
|
|
44
48
|
const messages = new Map<string, HistoryMessage>();
|
|
45
|
-
const addEntry = (entry: Entry,
|
|
49
|
+
const addEntry = (entry: Entry, injected?: boolean, settlement?: AttentionSettlement): void => {
|
|
46
50
|
const message_ref = { sender: entry.message.sender, id: entry.message.id };
|
|
47
51
|
const id = identity(message_ref);
|
|
48
52
|
const previous = messages.get(id);
|
|
53
|
+
const closed = previous?.settlement ?? settlement;
|
|
49
54
|
messages.set(id, {
|
|
50
|
-
message_ref, message: entry.message, direction: entry.message.sender === this
|
|
55
|
+
message_ref, message: entry.message, direction: entry.message.sender === this.#ctx.node ? 'out' : 'in',
|
|
51
56
|
thread_ref: entry.thread_ref, seq: entry.seq, at: entry.at, delivery: previous?.delivery ?? 'received',
|
|
52
57
|
...(previous?.guarantee === undefined ? {} : { guarantee: previous.guarantee }),
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
// A settlement closes the row without asserting a read, so it carries no consumption.
|
|
59
|
+
...(closed !== undefined ? { settlement: closed }
|
|
60
|
+
: injected === undefined ? previous?.consumption === undefined ? {} : { consumption: previous.consumption }
|
|
61
|
+
: { consumption: injected || previous?.consumption === 'consumed' ? 'consumed' : 'pending' }),
|
|
55
62
|
});
|
|
56
63
|
};
|
|
57
64
|
for (const { value } of await tx.scan<Entry>('entry-ref/')) addEntry(value);
|
|
58
65
|
for (const { value } of await tx.scan<LocalAttention>('inbox/')) {
|
|
59
|
-
if (value.attention.entry !== undefined && value.attention.disposition !== 'expired')
|
|
66
|
+
if (value.attention.entry !== undefined && value.attention.disposition !== 'expired') {
|
|
67
|
+
addEntry(value.attention.entry, value.injected, value.settlement);
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
70
|
const confirmed = new Set<string>();
|
|
62
71
|
for (const { value } of await tx.scan<{ thread_ref: ThreadRef; seq: string; confirmed: boolean }>('consumed/')) {
|
|
@@ -71,6 +80,7 @@ export class HistoryService {
|
|
|
71
80
|
delivery: value.state,
|
|
72
81
|
...(value.receipt === undefined ? {} : { thread_ref: value.receipt.thread_ref, seq: value.receipt.seq, guarantee: value.receipt.guarantee }),
|
|
73
82
|
...(value.error === undefined ? {} : { error: value.error }),
|
|
83
|
+
...(previous?.settlement === undefined ? {} : { settlement: previous.settlement }),
|
|
74
84
|
});
|
|
75
85
|
}
|
|
76
86
|
for (const row of messages.values()) {
|
package/src/http.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { OnlyneError, canonical, fail, same } from './core.
|
|
1
|
+
import { OnlyneError, canonical, fail, same } from './core.ts';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import type { Context } from './core.
|
|
3
|
+
import type { Context } from './core.ts';
|
|
4
4
|
import type {
|
|
5
5
|
Auth, DeliveryPlan, DomainMutation, Event, Grant, HandoverProbe, Json, LogQuery, Member, Message, MessageQuery,
|
|
6
6
|
Mutation, PageOptions, OperationRequest, ReadScope, Resource, ResourceEvent, SendRequest,
|
|
7
|
-
} from './types.
|
|
8
|
-
import type { ThreadService } from './threads.
|
|
9
|
-
import type { MessageService } from './messages.
|
|
10
|
-
import type { ResourceService } from './resources.
|
|
11
|
-
import type { DomainService } from './domains.
|
|
12
|
-
import type { DiscoveryService } from './discovery.
|
|
13
|
-
import type { KeyService, TrustService } from './keys.
|
|
14
|
-
import { SIGNATURE_HEADER, bodyDigest } from './keys.
|
|
15
|
-
import type { TrustStatement } from './types.
|
|
16
|
-
import { validateDomainId } from './domains.
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
import type { ThreadService } from './threads.ts';
|
|
9
|
+
import type { MessageService } from './messages.ts';
|
|
10
|
+
import type { ResourceService } from './resources.ts';
|
|
11
|
+
import type { DomainService } from './domains.ts';
|
|
12
|
+
import type { DiscoveryService } from './discovery.ts';
|
|
13
|
+
import type { KeyService, TrustService } from './keys.ts';
|
|
14
|
+
import { SIGNATURE_HEADER, bodyDigest } from './keys.ts';
|
|
15
|
+
import type { TrustStatement } from './types.ts';
|
|
16
|
+
import { validateDomainId } from './domains.ts';
|
|
17
17
|
|
|
18
18
|
const PREFIX = '/onlyne/v3';
|
|
19
19
|
const DEFAULT_BYTES = 1_048_576;
|
|
@@ -261,14 +261,18 @@ function sendRequest(request: Request, value: unknown, at: { owner: string; id:
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
function grant(value: unknown): Grant {
|
|
264
|
-
const raw = shape(value, 'grant', ['id', 'recipient', 'thread_ref', 'revision', 'acked']);
|
|
264
|
+
const raw = shape(value, 'grant', ['id', 'recipient', 'thread_ref', 'revision', 'acked', 'previous_thread_ref']);
|
|
265
265
|
if (typeof raw.acked !== 'boolean') fail('invalid_request', 'grant.acked must be a boolean.');
|
|
266
|
+
const previous = raw.previous_thread_ref === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: threadRef(raw.previous_thread_ref, 'grant.previous_thread_ref');
|
|
266
269
|
return {
|
|
267
270
|
id: string(raw.id, 'grant.id'),
|
|
268
271
|
recipient: string(raw.recipient, 'grant.recipient'),
|
|
269
272
|
thread_ref: threadRef(raw.thread_ref, 'grant.thread_ref'),
|
|
270
273
|
revision: string(raw.revision, 'grant.revision'),
|
|
271
274
|
acked: raw.acked,
|
|
275
|
+
...(previous === undefined ? {} : { previous_thread_ref: previous }),
|
|
272
276
|
};
|
|
273
277
|
}
|
|
274
278
|
|
|
@@ -624,7 +628,14 @@ export function createHttpHandler({ ctx, threads, messages, resources, domains,
|
|
|
624
628
|
}
|
|
625
629
|
|
|
626
630
|
if (path.length === 1 && path[0] === 'threads') {
|
|
627
|
-
if (request.method === 'GET')
|
|
631
|
+
if (request.method === 'GET') {
|
|
632
|
+
const joined = params.get('joined');
|
|
633
|
+
if (joined !== null) {
|
|
634
|
+
if (joined !== '1') fail('invalid_request', 'joined takes the value 1.');
|
|
635
|
+
return respond(await threads.joined(auth, pageOptions(params)));
|
|
636
|
+
}
|
|
637
|
+
return respond(await threads.list(auth, pageOptions(params)));
|
|
638
|
+
}
|
|
628
639
|
if (request.method === 'POST') {
|
|
629
640
|
const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until', 'title', 'id', 'default']);
|
|
630
641
|
if (raw.default !== undefined && typeof raw.default !== 'boolean') fail('invalid_request', 'default must be a boolean.');
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
export type * from './types.
|
|
2
|
-
export { trustPhase } from './types.
|
|
3
|
-
export { OnlyneError } from './core.
|
|
4
|
-
export { OnlyneNode } from './node.
|
|
5
|
-
export { ThreadService } from './threads.
|
|
6
|
-
export { MessageService } from './messages.
|
|
7
|
-
export { HistoryService } from './history.
|
|
8
|
-
export { createResourceService } from './resources.
|
|
9
|
-
export type { ResourceService } from './resources.
|
|
10
|
-
export { DomainService } from './domains.
|
|
11
|
-
export { DiscoveryService } from './discovery.
|
|
12
|
-
export { OnlyneClient } from './client.
|
|
13
|
-
export type { SendOutcome, SendOptions, MsgOptions } from './client.
|
|
14
|
-
export { FetchTransport } from './transport.
|
|
15
|
-
export type { SigningTransportOptions, SigningTrust, TrustFailure } from './transport.
|
|
16
|
-
export { KeyService, TrustService, keyFingerprint, generateKeyMaterial, SIGNATURE_HEADER, CLOCK_SKEW_MS, EXCHANGE_RETRY_MS, REFUSED_RETRY_MS } from './keys.
|
|
17
|
-
export { createHttpHandler } from './http.
|
|
18
|
-
export { MemoryStorage } from './memory.
|
|
1
|
+
export type * from './types.ts';
|
|
2
|
+
export { trustPhase } from './types.ts';
|
|
3
|
+
export { OnlyneError } from './core.ts';
|
|
4
|
+
export { OnlyneNode } from './node.ts';
|
|
5
|
+
export { ThreadService } from './threads.ts';
|
|
6
|
+
export { MessageService } from './messages.ts';
|
|
7
|
+
export { HistoryService } from './history.ts';
|
|
8
|
+
export { createResourceService } from './resources.ts';
|
|
9
|
+
export type { ResourceService } from './resources.ts';
|
|
10
|
+
export { DomainService } from './domains.ts';
|
|
11
|
+
export { DiscoveryService } from './discovery.ts';
|
|
12
|
+
export { OnlyneClient } from './client.ts';
|
|
13
|
+
export type { SendOutcome, SendOptions, MsgOptions } from './client.ts';
|
|
14
|
+
export { FetchTransport } from './transport.ts';
|
|
15
|
+
export type { SigningTransportOptions, SigningTrust, TrustFailure } from './transport.ts';
|
|
16
|
+
export { KeyService, TrustService, keyFingerprint, generateKeyMaterial, SIGNATURE_HEADER, CLOCK_SKEW_MS, EXCHANGE_RETRY_MS, REFUSED_RETRY_MS } from './keys.ts';
|
|
17
|
+
export { createHttpHandler } from './http.ts';
|
|
18
|
+
export { MemoryStorage } from './memory.ts';
|