@onlyne/lib 0.0.0-stage → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/http.ts ADDED
@@ -0,0 +1,852 @@
1
+ import { OnlyneError, canonical, fail, same } from './core.ts';
2
+ import { createHash } from 'node:crypto';
3
+ import type { Context } from './core.ts';
4
+ import type {
5
+ Auth, DeliveryPlan, DomainMutation, Event, Grant, HandoverProbe, Json, LogQuery, Member, Message, MessageQuery,
6
+ Mutation, PageOptions, OperationRequest, ReadScope, Resource, ResourceEvent, SendRequest,
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
+
18
+ const PREFIX = '/onlyne/v3';
19
+ const DEFAULT_BYTES = 1_048_576;
20
+ const STREAM_TICK_MS = 15_000;
21
+ const STREAM_MAX_PAGES = 1_000;
22
+
23
+ export interface HttpServices {
24
+ ctx: Context;
25
+ threads: ThreadService;
26
+ messages: MessageService;
27
+ resources: ResourceService;
28
+ domains: DomainService;
29
+ discovery?: DiscoveryService;
30
+ keys?: KeyService;
31
+ trust?: TrustService;
32
+ }
33
+
34
+ function record(value: unknown, field: string): Record<string, unknown> {
35
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) fail('invalid_request', `${field} must be a JSON object.`);
36
+ return value as Record<string, unknown>;
37
+ }
38
+
39
+ /** Rejects keys this endpoint does not route on, so a typo cannot silently change meaning. */
40
+ function shape(value: unknown, field: string, keys: readonly string[]): Record<string, unknown> {
41
+ const object = record(value, field);
42
+ for (const name of Object.keys(object)) {
43
+ if (!keys.includes(name)) fail('invalid_request', `${field}.${name} is not a field this endpoint accepts.`);
44
+ }
45
+ return object;
46
+ }
47
+
48
+ function string(value: unknown, field: string): string {
49
+ if (typeof value !== 'string' || value.length === 0) fail('invalid_request', `${field} must be a non-empty string.`);
50
+ return value;
51
+ }
52
+
53
+ function optionalString(value: unknown, field: string): string | undefined {
54
+ if (value === undefined) return undefined;
55
+ return string(value, field);
56
+ }
57
+
58
+ function list<T>(value: unknown, field: string, item: (entry: unknown, at: string) => T): T[] {
59
+ if (!Array.isArray(value)) fail('invalid_request', `${field} must be an array.`);
60
+ return value.map((entry, index) => item(entry, `${field}[${index}]`));
61
+ }
62
+
63
+ function threadRef(value: unknown, field: string): { owner: string; id: string } {
64
+ const raw = shape(value, field, ['owner', 'id']);
65
+ return { owner: string(raw.owner, `${field}.owner`), id: string(raw.id, `${field}.id`) };
66
+ }
67
+
68
+ function messageRef(value: unknown, field: string): { sender: string; id: string } {
69
+ const raw = shape(value, field, ['sender', 'id']);
70
+ return { sender: string(raw.sender, `${field}.sender`), id: string(raw.id, `${field}.id`) };
71
+ }
72
+
73
+ function resourceRef(value: unknown, field: string): { owner: string; id: string } {
74
+ const raw = shape(value, field, ['owner', 'id']);
75
+ return { owner: string(raw.owner, `${field}.owner`), id: string(raw.id, `${field}.id`) };
76
+ }
77
+
78
+ function deliveryPlan(value: unknown, field: string): DeliveryPlan {
79
+ const raw = shape(value, field, ['response', 'execution', 'context']);
80
+ const response = string(raw.response, `${field}.response`);
81
+ if (response !== 'none' && response !== 'async' && response !== 'await') fail('invalid_request', `${field}.response must be none, async or await.`);
82
+ const execution = string(raw.execution, `${field}.execution`);
83
+ if (execution !== 'existing' && execution !== 'new') fail('invalid_request', `${field}.execution must be existing or new.`);
84
+ const context = optionalString(raw.context, `${field}.context`);
85
+ return { response, execution, ...(context === undefined ? {} : { context }) };
86
+ }
87
+
88
+ function readScope(value: unknown, field: string): ReadScope {
89
+ const raw = shape(value, field, ['peers', 'since_seq', 'related_to']);
90
+ const since = optionalString(raw.since_seq, `${field}.since_seq`);
91
+ if (since !== undefined && !/^(0|[1-9][0-9]*)$/.test(since)) fail('invalid_request', `${field}.since_seq must be a decimal sequence string.`);
92
+ return {
93
+ ...(raw.peers === undefined ? {} : { peers: list(raw.peers, `${field}.peers`, (entry, at) => string(entry, at)) }),
94
+ ...(since === undefined ? {} : { since_seq: since }),
95
+ ...(raw.related_to === undefined ? {} : { related_to: messageRef(raw.related_to, `${field}.related_to`) }),
96
+ };
97
+ }
98
+
99
+ function messageMeta(value: unknown, field: string): NonNullable<Message['meta']> {
100
+ const raw = shape(value, field, ['thread', 'in_reply_to', 'relation', 'mentions', 'parts', 'data']);
101
+ const relation = optionalString(raw.relation, `${field}.relation`);
102
+ if (relation !== undefined && relation !== 'reply' && relation !== 'followup') fail('invalid_request', `${field}.relation must be reply or followup.`);
103
+ return {
104
+ ...(raw.thread === undefined ? {} : { thread: threadRef(raw.thread, `${field}.thread`) }),
105
+ ...(raw.in_reply_to === undefined ? {} : { in_reply_to: messageRef(raw.in_reply_to, `${field}.in_reply_to`) }),
106
+ ...(relation === undefined ? {} : { relation }),
107
+ ...(raw.mentions === undefined ? {} : { mentions: list(raw.mentions, `${field}.mentions`, (entry, at) => string(entry, at)) }),
108
+ ...(raw.parts === undefined ? {} : { parts: list(raw.parts, `${field}.parts`, entry => entry as Json) }),
109
+ ...(raw.data === undefined ? {} : { data: record(raw.data, `${field}.data`) as NonNullable<Message['meta']>['data'] }),
110
+ };
111
+ }
112
+
113
+ function message(value: unknown, field: string): Message {
114
+ const raw = shape(value, field, ['id', 'sender', 'delivery_kind', 'msg', 'meta']);
115
+ return {
116
+ id: string(raw.id, `${field}.id`),
117
+ sender: string(raw.sender, `${field}.sender`),
118
+ delivery_kind: string(raw.delivery_kind, `${field}.delivery_kind`),
119
+ msg: string(raw.msg, `${field}.msg`),
120
+ ...(raw.meta === undefined ? {} : { meta: messageMeta(raw.meta, `${field}.meta`) }),
121
+ };
122
+ }
123
+
124
+ function memberPatch(value: Record<string, unknown>, field: string): Partial<Member> {
125
+ const state = optionalString(value.state, `${field}.state`);
126
+ if (state !== undefined && state !== 'requested' && state !== 'active' && state !== 'revoked' && state !== 'denied') {
127
+ fail('invalid_request', `${field}.state must be requested, active, revoked or denied.`);
128
+ }
129
+ const access = value.access === undefined ? undefined : list(value.access, `${field}.access`, (entry, at) => {
130
+ const name = string(entry, at);
131
+ if (name !== 'read' && name !== 'post') fail('invalid_request', `${at} must be read or post.`);
132
+ return name;
133
+ });
134
+ return {
135
+ ...(value.peer === undefined ? {} : { peer: string(value.peer, `${field}.peer`) }),
136
+ ...(state === undefined ? {} : { state: state as Member['state'] }),
137
+ ...(access === undefined ? {} : { access: access as Member['access'] }),
138
+ ...(value.read_scope === undefined ? {} : { read_scope: readScope(value.read_scope, `${field}.read_scope`) }),
139
+ };
140
+ }
141
+
142
+ function pageOptions(params: URLSearchParams): PageOptions {
143
+ const cursor = params.get('cursor');
144
+ const size = params.get('page_size');
145
+ if (size !== null && !/^([1-9][0-9]?|100)$/.test(size)) fail('invalid_request', 'page_size must be from 1 to 100.');
146
+ return { ...(cursor === null ? {} : { cursor }), ...(size === null ? {} : { page_size: Number(size) }) };
147
+ }
148
+
149
+ /** RFC 0006 §6: `since` and `limit` are the log's own query names; `cursor` pages past the last page. */
150
+ function logQuery(params: URLSearchParams): LogQuery {
151
+ const cursor = params.get('cursor');
152
+ const since = params.get('since');
153
+ const limit = params.get('limit');
154
+ if (since !== null && !/^(0|[1-9][0-9]*)$/.test(since)) fail('invalid_request', 'since must be a decimal sequence string.');
155
+ if (limit !== null && !/^([1-9][0-9]?|100)$/.test(limit)) fail('invalid_request', 'limit must be from 1 to 100.');
156
+ return {
157
+ ...(cursor === null ? {} : { cursor }),
158
+ ...(since === null ? {} : { since }),
159
+ ...(limit === null ? {} : { page_size: Number(limit) }),
160
+ };
161
+ }
162
+
163
+ function messageQuery(params: URLSearchParams): MessageQuery {
164
+ const attention = params.get('attention');
165
+ if (attention !== null && attention !== 'inbox' && attention !== 'thread') fail('invalid_request', 'attention must be inbox or thread.');
166
+ const since = params.get('since_seq');
167
+ if (since !== null && !/^(0|[1-9][0-9]*)$/.test(since)) fail('invalid_request', 'since_seq must be a decimal sequence string.');
168
+ const related = params.get('related_to');
169
+ let related_to: MessageQuery['related_to'];
170
+ if (related !== null) {
171
+ let parsed: unknown;
172
+ try { parsed = JSON.parse(related); } catch { fail('invalid_request', 'related_to must encode a MessageRef as JSON.'); }
173
+ try { related_to = messageRef(parsed, 'related_to'); } catch (error) {
174
+ if (error instanceof OnlyneError) fail('invalid_request', 'related_to must encode a {sender, id} MessageRef.');
175
+ throw error;
176
+ }
177
+ }
178
+ return {
179
+ ...pageOptions(params),
180
+ ...(attention === null ? {} : { attention }),
181
+ ...(since === null ? {} : { since_seq: since }),
182
+ ...(params.get('peer') === null ? {} : { peer: string(params.get('peer'), 'peer') }),
183
+ ...(related_to === undefined ? {} : { related_to }),
184
+ };
185
+ }
186
+
187
+ async function json(request: Request, limit: number, rawBody?: string): Promise<unknown> {
188
+ const declared = request.headers.get('content-length');
189
+ if (declared !== null && /^[0-9]+$/.test(declared) && Number(declared) > limit) {
190
+ fail('payload_too_large', `The request body exceeds ${limit} bytes.`);
191
+ }
192
+ const type = (request.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
193
+ if (type !== 'application/json') fail('unsupported_media_type', 'Content-Type must be application/json.');
194
+ if (rawBody !== undefined) {
195
+ if (rawBody.length === 0) fail('invalid_request', 'A JSON body is required.');
196
+ try {
197
+ return JSON.parse(rawBody);
198
+ } catch {
199
+ fail('invalid_request', 'The request body is not valid UTF-8 JSON.');
200
+ }
201
+ }
202
+ if (request.body === null) fail('invalid_request', 'A JSON body is required.');
203
+ const chunks: Uint8Array[] = [];
204
+ let total = 0;
205
+ const reader = request.body.getReader();
206
+ while (true) {
207
+ const { done, value } = await reader.read();
208
+ if (done === true) break;
209
+ total += value.byteLength;
210
+ if (total > limit) fail('payload_too_large', `The request body exceeds ${limit} bytes.`);
211
+ chunks.push(value);
212
+ }
213
+ if (total === 0) fail('invalid_request', 'A JSON body is required.');
214
+ const bytes = new Uint8Array(total);
215
+ let offset = 0;
216
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
217
+ try {
218
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
219
+ } catch {
220
+ fail('invalid_request', 'The request body is not valid UTF-8 JSON.');
221
+ }
222
+ }
223
+
224
+ function mutation(request: Request, raw: Record<string, unknown>): Mutation {
225
+ const header = request.headers.get('idempotency-key');
226
+ const field = optionalString(raw.idempotency_key, 'idempotency_key');
227
+ if (header !== null && field !== undefined && header !== field) {
228
+ fail('invalid_request', 'Idempotency-Key and idempotency_key disagree.');
229
+ }
230
+ const idempotency_key = header ?? field;
231
+ if (idempotency_key === undefined) fail('invalid_request', 'An Idempotency-Key header is required.');
232
+ const headerDeadline = request.headers.get('x-onlyne-retry-until');
233
+ const fieldDeadline = optionalString(raw.retry_until, 'retry_until');
234
+ if (headerDeadline !== null && fieldDeadline !== undefined && headerDeadline !== fieldDeadline) {
235
+ fail('invalid_request', 'X-Onlyne-Retry-Until and retry_until disagree.');
236
+ }
237
+ return { idempotency_key, retry_until: string(headerDeadline ?? fieldDeadline, 'retry_until') };
238
+ }
239
+
240
+ /** DELETE carries no body, so its mutation comes from the headers and is checked the same way. */
241
+ function headerMutation(request: Request): Mutation {
242
+ return mutation(request, {});
243
+ }
244
+
245
+ function sendRequest(request: Request, value: unknown, at: { owner: string; id: string } | undefined): SendRequest {
246
+ const raw = shape(value, 'body', ['idempotency_key', 'retry_until', 'message', 'target_endpoint', 'plan']);
247
+ const content = message(raw.message, 'message');
248
+ if (at !== undefined) {
249
+ const declared = content.meta?.thread;
250
+ if (declared !== undefined && (declared.owner !== at.owner || declared.id !== at.id)) {
251
+ fail('invalid_request', 'The message thread must match the request path.');
252
+ }
253
+ content.meta = { ...content.meta, thread: at };
254
+ }
255
+ return {
256
+ ...mutation(request, raw),
257
+ message: content,
258
+ target_endpoint: string(raw.target_endpoint, 'target_endpoint'),
259
+ plan: deliveryPlan(raw.plan, 'plan'),
260
+ };
261
+ }
262
+
263
+ function grant(value: unknown): Grant {
264
+ const raw = shape(value, 'grant', ['id', 'recipient', 'thread_ref', 'revision', 'acked', 'previous_thread_ref']);
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');
269
+ return {
270
+ id: string(raw.id, 'grant.id'),
271
+ recipient: string(raw.recipient, 'grant.recipient'),
272
+ thread_ref: threadRef(raw.thread_ref, 'grant.thread_ref'),
273
+ revision: string(raw.revision, 'grant.revision'),
274
+ acked: raw.acked,
275
+ ...(previous === undefined ? {} : { previous_thread_ref: previous }),
276
+ };
277
+ }
278
+
279
+ function resourceRecord(value: unknown): Resource {
280
+ const raw = shape(value, 'resource', [
281
+ 'resource_ref', 'kind', 'operations', 'binding', 'log_readers', 'revision', 'event_seq', 'retention_ms', 'data',
282
+ ]);
283
+ const binding = shape(raw.binding, 'resource.binding', ['handler', 'cursor']);
284
+ const cursor = string(binding.cursor, 'resource.binding.cursor');
285
+ if (!/^(0|[1-9][0-9]*)$/.test(cursor)) fail('invalid_request', 'resource.binding.cursor must be a decimal sequence string.');
286
+ const nodes = (name: string) => list(raw[name] ?? [], `resource.${name}`, (entry, at) => string(entry, at));
287
+ const eventSeq = optionalString(raw.event_seq, 'resource.event_seq');
288
+ if (eventSeq !== undefined && !/^(0|[1-9][0-9]*)$/.test(eventSeq)) fail('invalid_request', 'resource.event_seq must be a decimal sequence string.');
289
+ if (raw.retention_ms !== undefined && (typeof raw.retention_ms !== 'number' || !Number.isInteger(raw.retention_ms) || raw.retention_ms < 0)) {
290
+ fail('invalid_request', 'resource.retention_ms must be a non-negative integer.');
291
+ }
292
+ return {
293
+ resource_ref: resourceRef(raw.resource_ref, 'resource.resource_ref'),
294
+ kind: string(raw.kind, 'resource.kind'),
295
+ operations: list(raw.operations ?? [], 'resource.operations', (entry, at) => string(entry, at)),
296
+ binding: { handler: string(binding.handler, 'resource.binding.handler'), cursor },
297
+ log_readers: nodes('log_readers'),
298
+ revision: string(raw.revision, 'resource.revision'),
299
+ event_seq: eventSeq ?? '0',
300
+ retention_ms: (raw.retention_ms as number | undefined) ?? 0,
301
+ ...(raw.data === undefined ? {} : { data: raw.data as Resource['data'] }),
302
+ };
303
+ }
304
+
305
+ function probeBody(value: unknown): HandoverProbe {
306
+ const raw = shape(value, 'body', ['operation_id', 'resource', 'kind', 'operations', 'metadata', 'initiator', 'cursor', 'deadline']);
307
+ return {
308
+ operation_id: string(raw.operation_id, 'operation_id'),
309
+ resource: resourceRef(raw.resource, 'resource'),
310
+ kind: string(raw.kind, 'kind'),
311
+ operations: list(raw.operations, 'operations', (entry, at) => string(entry, at)),
312
+ ...(raw.metadata === undefined ? {} : { metadata: raw.metadata as Json }),
313
+ initiator: string(raw.initiator, 'initiator'),
314
+ cursor: string(raw.cursor, 'cursor'),
315
+ deadline: string(raw.deadline, 'deadline'),
316
+ };
317
+ }
318
+
319
+ function operationRequest(request: Request, value: unknown): OperationRequest {
320
+ const raw = shape(value, 'body', ['idempotency_key', 'retry_until', 'operation_id', 'operation', 'expected_revision', 'data']);
321
+ return {
322
+ ...mutation(request, raw),
323
+ operation_id: string(raw.operation_id, 'operation_id'),
324
+ operation: string(raw.operation, 'operation'),
325
+ ...(raw.expected_revision === undefined ? {} : { expected_revision: string(raw.expected_revision, 'expected_revision') }),
326
+ ...(raw.data === undefined ? {} : { data: raw.data as Json }),
327
+ };
328
+ }
329
+
330
+ function eventBody(value: unknown): { source_id: string; data: Json; expires_at?: string } {
331
+ const raw = shape(value, 'body', ['source_id', 'data', 'expires_at']);
332
+ if (!('data' in raw)) fail('invalid_request', 'data is required.');
333
+ return {
334
+ source_id: string(raw.source_id, 'source_id'),
335
+ data: raw.data as Json,
336
+ ...(raw.expires_at === undefined ? {} : { expires_at: string(raw.expires_at, 'expires_at') }),
337
+ };
338
+ }
339
+
340
+ /**
341
+ * The public description: identity, address, current region declaration and configured
342
+ * authentication schemes. It carries no credential subject, message or resource.
343
+ */
344
+ async function description(ctx: Context, domains: DomainService, discovered: boolean): Promise<Json> {
345
+ const active = (await domains.list({ actor: ctx.node }, { page_size: 100 })).items.find(membership => membership.state === 'active');
346
+ return {
347
+ protocol: 'onlyne/3',
348
+ node_id: ctx.node,
349
+ instance_id: ctx.instanceId,
350
+ base_url: ctx.baseUrl,
351
+ features: [
352
+ 'threads', 'messages', 'events', 'resources', 'domains',
353
+ ...(discovered ? ['discovery'] : []),
354
+ ...(ctx.options.plugins.executionContexts === undefined ? [] : ['contexts']),
355
+ ],
356
+ // The binding names only schemes its host configured; a credential's subject is not a scheme.
357
+ authentication: ctx.options.authentication ?? [],
358
+ domain: active?.domain_id ?? null,
359
+ membership: active === undefined
360
+ ? null
361
+ : { domain_id: active.domain_id, node_id: active.node_id, state: active.state, revision: active.revision },
362
+ limits: { request_bytes: ctx.options.request_bytes ?? DEFAULT_BYTES },
363
+ idempotency_max_seconds: Math.floor((ctx.options.retry_max_ms ?? 86_400_000) / 1000),
364
+ };
365
+ }
366
+
367
+ /** A resume cursor this binding issues: a decimal event position bound to one stream scope. */
368
+ function eventCursor(value: unknown): { scope: unknown; after: string } | undefined {
369
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
370
+ if (!('after' in value) || !('scope' in value)) return undefined;
371
+ const { after, scope } = value;
372
+ if (typeof after !== 'string' || !/^(0|[1-9][0-9]*)$/.test(after)) return undefined;
373
+ return { scope, after };
374
+ }
375
+
376
+ /** The RFC 0017 content digest: SHA-256 hex of the exact bytes the request carried. */
377
+ function digestOf(text: string): string {
378
+ return createHash('sha256').update(text, 'utf8').digest('hex');
379
+ }
380
+
381
+ function respond(value: unknown, status = 200): Response {
382
+ return new Response(JSON.stringify(value ?? null), {
383
+ status,
384
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
385
+ });
386
+ }
387
+
388
+ function failure(error: unknown): Response {
389
+ const onlyne = error instanceof OnlyneError ? error : new OnlyneError('internal', 'The node failed to complete the request.');
390
+ return respond({ error: onlyne.error }, onlyne.status);
391
+ }
392
+
393
+ function expect(request: Request, allowed: string): void {
394
+ if (request.method !== allowed) fail('not_found', `This endpoint does not accept ${request.method}.`);
395
+ }
396
+
397
+ /** Maps `/onlyne/v3` HTTP requests onto the services. The caller owns the server lifecycle. */
398
+ export function createHttpHandler({ ctx, threads, messages, resources, domains, discovery, keys, trust }: HttpServices): (request: Request) => Promise<Response> {
399
+ const limit = ctx.options.request_bytes ?? DEFAULT_BYTES;
400
+
401
+ /**
402
+ * RFC 0017: an `Onlyne-Sig` header is verified against the peer's stored key before the
403
+ * host plugin sees the request; a verified signature names the actor directly. The
404
+ * signature covers the body, so it is checked against the raw bytes actually received.
405
+ */
406
+ async function authenticate(request: Request, rawBody: string): Promise<Auth> {
407
+ // A keyed node never falls back to an unauthenticated actor: the signature is the credential.
408
+ if (trust !== undefined && keys?.enabled === true) return trust.authenticate(request, bodyDigest(rawBody));
409
+ const auth = await ctx.options.plugins.authenticate(request);
410
+ if (auth === null || typeof auth.actor !== 'string' || auth.actor.length === 0) fail('unauthenticated', 'Authentication is required.');
411
+ return auth;
412
+ }
413
+
414
+ /** RFC 0017: an authenticated answer to a signed request carries the responder's signature. */
415
+ async function signed(request: Request, response: Response): Promise<Response> {
416
+ if (trust === undefined || keys?.enabled !== true || request.headers.get(SIGNATURE_HEADER) === null) return response;
417
+ await trust.signResponse(request, response, await response.clone().text());
418
+ return response;
419
+ }
420
+
421
+ /**
422
+ * Polls the event page on a timer and writes SSE frames. Every frame carries its own `id:`: a
423
+ * cursor bound to this node, actor, ThreadRef and query scope, positioned after the event the
424
+ * frame delivered. A frame that reused one page cursor would replay the whole page on resume.
425
+ * The resume value is validated against that same scope before the response exists, the first
426
+ * page is fetched up front so a lost credential or expired cursor answers with an HTTP error
427
+ * instead of a 200 stream, and every later page re-checks the credential.
428
+ */
429
+ async function stream(request: Request, auth: Auth, ref: { owner: string; id: string }): Promise<Response> {
430
+ const encoder = new TextEncoder();
431
+ const scope = { ref, actor: auth.actor, kind: 'events' as const };
432
+ const resume = request.headers.get('last-event-id');
433
+ let cursor: string | undefined;
434
+ if (resume !== null && resume.length > 0) {
435
+ let decoded: unknown;
436
+ try {
437
+ decoded = JSON.parse(decodeURIComponent(resume));
438
+ } catch {
439
+ fail('invalid_request', 'Last-Event-ID is not a cursor this binding issues.');
440
+ }
441
+ const parsed = eventCursor(decoded);
442
+ if (parsed === undefined || !same(parsed.scope, scope)) {
443
+ fail('invalid_request', 'Last-Event-ID belongs to another node, actor or thread.');
444
+ }
445
+ cursor = resume;
446
+ }
447
+
448
+ let tick: ReturnType<typeof setInterval> | undefined;
449
+ let running = false;
450
+ let done = false;
451
+ let detach = () => {};
452
+
453
+ const write = (controller: ReadableStreamDefaultController<Uint8Array>, text: string) => {
454
+ if (done) return;
455
+ try {
456
+ controller.enqueue(encoder.encode(text));
457
+ } catch {
458
+ // The consumer closed the stream; release the tick and the abort listener with it.
459
+ done = true;
460
+ clearInterval(tick);
461
+ detach();
462
+ }
463
+ };
464
+ const stop = (controller: ReadableStreamDefaultController<Uint8Array>) => {
465
+ if (done) return;
466
+ done = true;
467
+ clearInterval(tick);
468
+ detach();
469
+ try { controller.close(); } catch { /* the consumer already closed it */ }
470
+ };
471
+ const emit = (controller: ReadableStreamDefaultController<Uint8Array>, event: Event) => {
472
+ cursor = encodeURIComponent(canonical({ scope, after: event.id }));
473
+ write(controller, `id: ${cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
474
+ };
475
+ const drain = async (controller: ReadableStreamDefaultController<Uint8Array>) => {
476
+ for (let pages = 0; pages < STREAM_MAX_PAGES && !done; pages++) {
477
+ const page = await messages.events(auth, ref, cursor === undefined ? {} : { cursor });
478
+ if (page.items.length === 0) {
479
+ write(controller, ': idle\n\n');
480
+ return;
481
+ }
482
+ for (const event of page.items) emit(controller, event);
483
+ if (page.next_cursor === null) return;
484
+ }
485
+ };
486
+ const poll = async (controller: ReadableStreamDefaultController<Uint8Array>) => {
487
+ if (running || done) return;
488
+ running = true;
489
+ try {
490
+ const current = keys?.enabled === true
491
+ ? ((await trust?.entry(auth.actor))?.state === 'trusted' ? { actor: auth.actor } : null)
492
+ : await ctx.options.plugins.authenticate(request);
493
+ if (current === null || current.actor !== auth.actor) {
494
+ write(controller, `event: error\ndata: ${JSON.stringify({ error: new OnlyneError('forbidden', 'The credential is no longer valid for this stream.').error })}\n\n`);
495
+ stop(controller);
496
+ return;
497
+ }
498
+ await drain(controller);
499
+ } catch (error) {
500
+ const onlyne = error instanceof OnlyneError ? error : new OnlyneError('internal', 'The event stream failed.');
501
+ write(controller, `event: error\ndata: ${JSON.stringify({ error: onlyne.error })}\n\n`);
502
+ stop(controller);
503
+ } finally {
504
+ running = false;
505
+ }
506
+ };
507
+
508
+ // The first page settles authorization and cursor validity while a status code can still say so.
509
+ const first = await messages.events(auth, ref, cursor === undefined ? {} : { cursor });
510
+ const body = new ReadableStream<Uint8Array>({
511
+ start(controller) {
512
+ for (const event of first.items) emit(controller, event);
513
+ if (first.items.length === 0) write(controller, ': idle\n\n');
514
+ if (request.signal.aborted) {
515
+ stop(controller);
516
+ return;
517
+ }
518
+ const onAbort = () => stop(controller);
519
+ detach = () => request.signal.removeEventListener('abort', onAbort);
520
+ request.signal.addEventListener('abort', onAbort, { once: true });
521
+ tick = setInterval(() => { void poll(controller); }, STREAM_TICK_MS);
522
+ if (first.next_cursor !== null) void poll(controller);
523
+ },
524
+ cancel() {
525
+ done = true;
526
+ clearInterval(tick);
527
+ detach();
528
+ },
529
+ });
530
+ return new Response(body, {
531
+ status: 200,
532
+ headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' },
533
+ });
534
+ }
535
+
536
+ async function handle(request: Request): Promise<Response> {
537
+ try {
538
+ const url = new URL(request.url);
539
+ if (!url.pathname.startsWith(`${PREFIX}/`)) fail('not_found', 'Unknown route.');
540
+ const path = url.pathname.slice(PREFIX.length).split('/').filter(segment => segment.length > 0).map(segment => {
541
+ try {
542
+ return decodeURIComponent(segment);
543
+ } catch {
544
+ return fail('invalid_request', 'The request path is not valid percent-encoding.');
545
+ }
546
+ });
547
+ const params = url.searchParams;
548
+
549
+ if (path.length === 1 && path[0] === 'description') {
550
+ expect(request, 'GET');
551
+ return respond(await description(ctx, domains, discovery !== undefined));
552
+ }
553
+
554
+ if (path.length === 1 && path[0] === 'discovery') {
555
+ expect(request, 'GET');
556
+ if (discovery === undefined) fail('not_found', 'Unknown route.');
557
+ return respond(await discovery.list(pageOptions(params)));
558
+ }
559
+
560
+ // RFC 0017: the identity lookup a first exchange reads. Public by design — a public key
561
+ // is not a secret, and the fingerprint is what the two users compare out of band.
562
+ if (path.length === 1 && path[0] === 'keys') {
563
+ expect(request, 'GET');
564
+ const identity = keys === undefined ? undefined : await keys.describe();
565
+ if (identity === undefined) fail('not_found', 'This node has no identity key.');
566
+ return respond(identity);
567
+ }
568
+
569
+ // The exchange transcript: each side signs (sender, recipient, key, base_url, approval,
570
+ // timestamp, nonce), so a captured statement cannot be replayed or retargeted.
571
+ if (path.length === 1 && path[0] === 'introductions') {
572
+ expect(request, 'POST');
573
+ if (trust === undefined) fail('not_found', 'Unknown route.');
574
+ const statement = await json(request, limit) as TrustStatement;
575
+ return respond(await trust.introduce(statement), 201);
576
+ }
577
+
578
+ // Authenticated routes read the raw body first: the signature covers exactly these bytes.
579
+ const rawBody = await request.text();
580
+ const auth = await authenticate(request, rawBody);
581
+
582
+ if (path.length === 1 && path[0] === 'contexts') {
583
+ expect(request, 'GET');
584
+ const contexts = ctx.options.plugins.executionContexts;
585
+ if (contexts === undefined) fail('unsupported_delivery', 'The host provides no execution context directory.');
586
+ return respond(await contexts());
587
+ }
588
+
589
+ if (path.length >= 1 && path[0] === 'trust' && trust !== undefined) {
590
+ if (path.length === 1) {
591
+ expect(request, 'GET');
592
+ return respond(await trust.list(auth));
593
+ }
594
+ if (path.length === 2 && path[1] === 'requests') {
595
+ expect(request, 'POST');
596
+ const raw = shape(await json(request, limit, rawBody), 'body', ['node_id', 'fingerprint']);
597
+ return respond(await trust.request(auth, string(raw.node_id, 'node_id'), string(raw.fingerprint, 'fingerprint')), 201);
598
+ }
599
+ if (path.length === 2 && path[1] === 'rotate') {
600
+ expect(request, 'POST');
601
+ return respond(await trust.rotate(auth));
602
+ }
603
+ if (path.length === 3 && path[1] === 'revoke') {
604
+ expect(request, 'POST');
605
+ await trust.revoke(auth, path[2]!);
606
+ return respond({ node_id: path[2]!, state: 'revoked' });
607
+ }
608
+ fail('not_found', 'Unknown route.');
609
+ }
610
+
611
+ if (path.length === 1 && path[0] === 'messages') {
612
+ expect(request, 'POST');
613
+ return respond(await messages.admit(auth, sendRequest(request, await json(request, limit, rawBody), undefined)), 201);
614
+ }
615
+
616
+ // RFC 0006 §4.3: the target side of a handover. A probe is a peer request like any
617
+ // other, so it passes the same signature check before it is read.
618
+ if (path.length >= 2 && path[0] === 'handover' && path[1] === 'probes') {
619
+ if (path.length === 2) {
620
+ expect(request, 'POST');
621
+ return respond(await resources.probe(auth, probeBody(await json(request, limit, rawBody))), 201);
622
+ }
623
+ if (path.length === 3) {
624
+ expect(request, 'GET');
625
+ return respond(await resources.probeAnswer(auth, path[2]!));
626
+ }
627
+ fail('not_found', 'Unknown route.');
628
+ }
629
+
630
+ if (path.length === 1 && path[0] === 'threads') {
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
+ }
639
+ if (request.method === 'POST') {
640
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until', 'title', 'id', 'default']);
641
+ if (raw.default !== undefined && typeof raw.default !== 'boolean') fail('invalid_request', 'default must be a boolean.');
642
+ const id = optionalString(raw.id, 'id');
643
+ const created = await threads.create(auth, {
644
+ ...mutation(request, raw),
645
+ title: string(raw.title, 'title'),
646
+ ...(id === undefined ? {} : { id }),
647
+ ...(raw.default === undefined ? {} : { default: raw.default }),
648
+ });
649
+ return respond(created, 201);
650
+ }
651
+ fail('not_found', 'Unknown route.');
652
+ }
653
+
654
+ if (path.length === 1 && path[0] === 'resources') {
655
+ if (request.method === 'GET') return respond(await resources.list(auth, pageOptions(params)));
656
+ if (request.method === 'POST') {
657
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until', 'resource']);
658
+ mutation(request, raw);
659
+ return respond(await resources.register(auth, resourceRecord(raw.resource)), 201);
660
+ }
661
+ fail('not_found', 'Unknown route.');
662
+ }
663
+
664
+ if (path.length === 1 && path[0] === 'grants') {
665
+ if (request.method === 'GET') return respond(await threads.grants(auth, pageOptions(params)));
666
+ if (request.method === 'POST') {
667
+ const raw = shape(await json(request, limit, rawBody), 'body', ['grant']);
668
+ return respond(await threads.acceptGrant(auth, grant(raw.grant)), 201);
669
+ }
670
+ fail('not_found', 'Unknown route.');
671
+ }
672
+
673
+ if (path.length === 3 && path[0] === 'grants' && path[2] === 'ack') {
674
+ expect(request, 'POST');
675
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until']);
676
+ return respond(await threads.ackGrant(auth, path[1]!, mutation(request, raw)));
677
+ }
678
+
679
+ if (path.length === 1 && path[0] === 'domains') {
680
+ if (request.method === 'GET') return respond(await domains.list(auth, pageOptions(params)));
681
+ if (request.method === 'POST') {
682
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until']);
683
+ // Membership belongs to the node that holds it; a peer's credential never mutates it.
684
+ ctx.owner(auth.actor);
685
+ return respond(await domains.create(auth, mutation(request, raw)), 201);
686
+ }
687
+ fail('not_found', 'Unknown route.');
688
+ }
689
+
690
+ if (path.length === 2 && path[0] === 'domains') {
691
+ expect(request, 'GET');
692
+ return respond(await domains.describe(auth, validateDomainId(path[1])));
693
+ }
694
+
695
+ if (path.length === 3 && path[0] === 'domains') {
696
+ const id = validateDomainId(path[1]);
697
+ if (path[2] === 'members') {
698
+ expect(request, 'GET');
699
+ return respond(await domains.discover(auth, id, request.signal));
700
+ }
701
+ if (path[2] === 'join' || path[2] === 'leave') {
702
+ expect(request, 'POST');
703
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until', 'expected_revision']);
704
+ const expected = optionalString(raw.expected_revision, 'expected_revision');
705
+ ctx.owner(auth.actor);
706
+ const change: DomainMutation = {
707
+ ...mutation(request, raw),
708
+ ...(expected === undefined ? {} : { expected_revision: expected }),
709
+ };
710
+ return respond(await (path[2] === 'join' ? domains.join(auth, id, change) : domains.leave(auth, id, change)));
711
+ }
712
+ fail('not_found', 'Unknown route.');
713
+ }
714
+
715
+ if (path.length >= 3 && path[0] === 'threads') {
716
+ const ref = { owner: path[1]!, id: path[2]! };
717
+ const tail = path[3];
718
+
719
+ if (tail === undefined) {
720
+ expect(request, 'GET');
721
+ return respond(await threads.describe(auth, ref));
722
+ }
723
+
724
+ if (tail === 'close') {
725
+ if (path.length !== 4) fail('not_found', 'Unknown route.');
726
+ expect(request, 'POST');
727
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until']);
728
+ return respond(await threads.close(auth, ref, mutation(request, raw)));
729
+ }
730
+
731
+ if (tail === 'memberships') {
732
+ if (path.length === 4) {
733
+ if (request.method === 'GET') return respond(await threads.members(auth, ref, pageOptions(params)));
734
+ if (request.method === 'POST') {
735
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until', 'peer', 'state', 'access', 'read_scope']);
736
+ return respond(await threads.member(auth, ref, string(raw.peer, 'peer'), memberPatch(raw, 'body'), mutation(request, raw)), 201);
737
+ }
738
+ fail('not_found', 'Unknown route.');
739
+ }
740
+ if (path.length === 5) {
741
+ expect(request, 'DELETE');
742
+ const peer = path[4] === 'self' ? auth.actor : path[4]!;
743
+ return respond(await threads.member(auth, ref, peer, { state: 'revoked' }, headerMutation(request)));
744
+ }
745
+ fail('not_found', 'Unknown route.');
746
+ }
747
+
748
+ if (tail === 'messages') {
749
+ if (path.length !== 4) fail('not_found', 'Unknown route.');
750
+ if (request.method === 'GET') return respond(await messages.list(auth, ref, messageQuery(params)));
751
+ if (request.method === 'POST') return respond(await messages.admit(auth, sendRequest(request, await json(request, limit, rawBody), ref)), 201);
752
+ fail('not_found', 'Unknown route.');
753
+ }
754
+
755
+ if (tail === 'snapshot') {
756
+ if (path.length !== 4) fail('not_found', 'Unknown route.');
757
+ expect(request, 'GET');
758
+ return respond(await messages.snapshot(auth, ref));
759
+ }
760
+
761
+ if (tail === 'attention') {
762
+ if (path.length === 4) {
763
+ expect(request, 'GET');
764
+ return respond(await messages.attention(auth, ref, pageOptions(params)));
765
+ }
766
+ if (path.length === 6 && path[5] === 'ack') {
767
+ expect(request, 'POST');
768
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until']);
769
+ return respond(await messages.ack(auth, ref, path[4]!, mutation(request, raw)));
770
+ }
771
+ fail('not_found', 'Unknown route.');
772
+ }
773
+
774
+ if (tail === 'events') {
775
+ if (path.length !== 4) fail('not_found', 'Unknown route.');
776
+ expect(request, 'GET');
777
+ // Awaited so an unauthorized or expired resume answers with a status, not a dead stream.
778
+ return await stream(request, auth, ref);
779
+ }
780
+
781
+ fail('not_found', 'Unknown route.');
782
+ }
783
+
784
+ if (path.length >= 3 && path[0] === 'resources') {
785
+ const ref = { owner: path[1]!, id: path[2]! };
786
+ const tail = path[3];
787
+
788
+ if (tail === undefined) {
789
+ expect(request, 'GET');
790
+ return respond(await resources.describe(auth, ref));
791
+ }
792
+
793
+ if (tail === 'operations') {
794
+ if (path.length === 4) {
795
+ if (request.method === 'GET') return respond(await resources.operations(auth, ref, pageOptions(params)));
796
+ if (request.method === 'POST') {
797
+ const operation = operationRequest(request, await json(request, limit, rawBody));
798
+ await ctx.options.plugins.prepareResourceOperation?.(auth, ref, operation);
799
+ return respond(await resources.operate(auth, ref, operation, request.signal), 201);
800
+ }
801
+ fail('not_found', 'Unknown route.');
802
+ }
803
+ if (path.length === 5) {
804
+ expect(request, 'GET');
805
+ return respond(await resources.operation(auth, ref, path[4]!));
806
+ }
807
+ fail('not_found', 'Unknown route.');
808
+ }
809
+
810
+ // RFC 0006 §6: the log is read on its own route, from the endpoint's storage and
811
+ // never through the binding, so it stays readable while the handler is away.
812
+ if (tail === 'log') {
813
+ if (path.length === 4) {
814
+ expect(request, 'GET');
815
+ return respond(await resources.log(auth, ref, logQuery(params)));
816
+ }
817
+ if (path.length === 5) {
818
+ expect(request, 'GET');
819
+ return respond(await resources.event(auth, ref, path[4]!));
820
+ }
821
+ fail('not_found', 'Unknown route.');
822
+ }
823
+
824
+ if (tail === 'events') {
825
+ if (path.length === 4) {
826
+ expect(request, 'POST');
827
+ const event: ResourceEvent = await resources.receive(auth, ref, eventBody(await json(request, limit, rawBody)));
828
+ return respond(event, 201);
829
+ }
830
+ if (path.length === 6 && path[5] === 'ack') {
831
+ expect(request, 'POST');
832
+ const raw = shape(await json(request, limit, rawBody), 'body', ['idempotency_key', 'retry_until']);
833
+ return respond(await resources.ack(auth, ref, path[4]!, mutation(request, raw)));
834
+ }
835
+ fail('not_found', 'Unknown route.');
836
+ }
837
+
838
+ fail('not_found', 'Unknown route.');
839
+ }
840
+
841
+ fail('not_found', 'Unknown route.');
842
+ } catch (error) {
843
+ return failure(error);
844
+ }
845
+ }
846
+
847
+ return async (request: Request): Promise<Response> => {
848
+ const response = await handle(request);
849
+ try { return await signed(request, response); }
850
+ catch (error) { return failure(error); }
851
+ };
852
+ }