@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/src/discovery.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { OnlyneError, fail, key, page, type Context } from './core.js';
2
- import { membershipRecord, validateDomainId } from './domains.js';
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.js';
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
- constructor(private readonly ctx: Context, private readonly fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch) {}
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.ctx.now();
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.ctx.storage.transaction(async tx => { await tx.put(key(PREFIX, record.node_id, base_url), record); });
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.ctx.now();
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.ctx.node }, position);
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.ctx.storage.transaction(tx => tx.scan<DiscoveryRecord>(`${PREFIX}/`));
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.ctx.now();
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.ctx.options.wait_ms ?? 30_000,
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.fetcher(target, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
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.ctx.storage.transaction(async tx => {
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 && Date.parse(row.value.expires_at) > at) {
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.js';
2
- import { serviceBase } from './transport.js';
3
- import type { Auth, DomainDiscovery, DomainMembership, DomainMutation, Mutation, PageOptions, Transaction } from './types.js';
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
- constructor(
60
- private readonly ctx: Context,
61
- private readonly fetcher: (input: string | URL | Request, init?: RequestInit) => Promise<Response> = fetch,
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.ctx.node) fail('forbidden', 'A node may only change its own domain membership.');
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.ctx.mutate(auth, 'domain.create', mutation, {}, async tx => {
76
- if (await activeMembership(tx, this.ctx.node)) {
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.ctx.id()}`);
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.ctx.node, state: 'active', revision: this.ctx.id() };
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.ctx.mutate(auth, `domain.${state}/${id}`, mutation, { expected_revision: mutation.expected_revision ?? null }, async tx => {
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.ctx.node);
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.ctx.id();
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.ctx.node, state, revision };
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.ctx.storage.transaction(async tx => {
123
- const current = await activeMembership(tx, this.ctx.node);
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.ctx.node, actor: auth.actor }, row => row.domain_id);
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.ctx.storage.transaction(async tx => requireValue(await tx.get<DomainMembership>(key('domain', id))));
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.ctx.options.plugins.domainCandidates;
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.ctx.node, ...await candidates(id)])].sort();
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.ctx.node) {
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.ctx.options.plugins.resolve(node);
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.ctx.options.wait_ms ?? 30_000);
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.fetcher(url, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
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.js';
2
- import { fail, key, page, same, type Context } from './core.js';
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
- constructor(private readonly ctx: Context) {}
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.ctx.storage.transaction(async tx => {
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.ctx.storage.transaction(async tx => {
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.ctx.node) fail('forbidden', 'Conversation history belongs to this node.');
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, consumed?: boolean): void => {
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.ctx.node ? 'out' : 'in',
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
- ...(consumed === undefined ? previous?.consumption === undefined ? {} : { consumption: previous.consumption }
54
- : { consumption: consumed || previous?.consumption === 'consumed' ? 'consumed' : 'pending' }),
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') addEntry(value.attention.entry, value.injected);
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.js';
1
+ import { OnlyneError, canonical, fail, same } from './core.ts';
2
2
  import { createHash } from 'node:crypto';
3
- import type { Context } from './core.js';
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.js';
8
- import type { ThreadService } from './threads.js';
9
- import type { MessageService } from './messages.js';
10
- import type { ResourceService } from './resources.js';
11
- import type { DomainService } from './domains.js';
12
- import type { DiscoveryService } from './discovery.js';
13
- import type { KeyService, TrustService } from './keys.js';
14
- import { SIGNATURE_HEADER, bodyDigest } from './keys.js';
15
- import type { TrustStatement } from './types.js';
16
- import { validateDomainId } from './domains.js';
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') return respond(await threads.list(auth, pageOptions(params)));
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.js';
2
- export { trustPhase } from './types.js';
3
- export { OnlyneError } from './core.js';
4
- export { OnlyneNode } from './node.js';
5
- export { ThreadService } from './threads.js';
6
- export { MessageService } from './messages.js';
7
- export { HistoryService } from './history.js';
8
- export { createResourceService } from './resources.js';
9
- export type { ResourceService } from './resources.js';
10
- export { DomainService } from './domains.js';
11
- export { DiscoveryService } from './discovery.js';
12
- export { OnlyneClient } from './client.js';
13
- export type { SendOutcome, SendOptions, MsgOptions } from './client.js';
14
- export { FetchTransport } from './transport.js';
15
- export type { SigningTransportOptions, SigningTrust, TrustFailure } from './transport.js';
16
- export { KeyService, TrustService, keyFingerprint, generateKeyMaterial, SIGNATURE_HEADER, CLOCK_SKEW_MS, EXCHANGE_RETRY_MS, REFUSED_RETRY_MS } from './keys.js';
17
- export { createHttpHandler } from './http.js';
18
- export { MemoryStorage } from './memory.js';
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';