@onlyne/lib 3.1.0 → 3.2.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 +4 -3
- package/dist/awaited.d.ts +43 -0
- package/dist/awaited.d.ts.map +1 -0
- package/dist/awaited.js +82 -0
- package/dist/awaited.js.map +1 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1772 -0
- package/dist/client.js.map +1 -0
- package/dist/core.d.ts +36 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +122 -0
- package/dist/core.js.map +1 -0
- package/dist/delivery.d.ts +120 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +117 -0
- package/dist/delivery.js.map +1 -0
- package/dist/discovery.d.ts +35 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +306 -0
- package/dist/discovery.js.map +1 -0
- package/dist/domains.d.ts +29 -0
- package/dist/domains.d.ts.map +1 -0
- package/dist/domains.js +267 -0
- package/dist/domains.js.map +1 -0
- package/dist/history.d.ts +13 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +143 -0
- package/dist/history.js.map +1 -0
- package/dist/http.d.ts +20 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +88 -0
- package/dist/http.js.map +1 -0
- package/{src/index.ts → dist/index.d.ts} +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +97 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +679 -0
- package/dist/keys.js.map +1 -0
- package/dist/memory.d.ts +9 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +35 -0
- package/dist/memory.js.map +1 -0
- package/dist/messages.d.ts +64 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +480 -0
- package/dist/messages.js.map +1 -0
- package/dist/node.d.ts +26 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +59 -0
- package/dist/node.js.map +1 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +37 -0
- package/dist/resolve.js.map +1 -0
- package/dist/resources.d.ts +25 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +782 -0
- package/dist/resources.js.map +1 -0
- package/dist/routes/domains.d.ts +4 -0
- package/dist/routes/domains.d.ts.map +1 -0
- package/dist/routes/domains.js +42 -0
- package/dist/routes/domains.js.map +1 -0
- package/dist/routes/grants.d.ts +8 -0
- package/dist/routes/grants.d.ts.map +1 -0
- package/dist/routes/grants.js +32 -0
- package/dist/routes/grants.js.map +1 -0
- package/dist/routes/public.d.ts +4 -0
- package/dist/routes/public.d.ts.map +1 -0
- package/dist/routes/public.js +62 -0
- package/dist/routes/public.js.map +1 -0
- package/dist/routes/resources.d.ts +8 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +87 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/shared.d.ts +77 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +349 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/threads.d.ts +8 -0
- package/dist/routes/threads.d.ts.map +1 -0
- package/dist/routes/threads.js +113 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/trust.d.ts +4 -0
- package/dist/routes/trust.d.ts.map +1 -0
- package/dist/routes/trust.js +36 -0
- package/dist/routes/trust.js.map +1 -0
- package/dist/stream.d.ts +20 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +141 -0
- package/dist/stream.js.map +1 -0
- package/dist/threads.d.ts +79 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/threads.js +480 -0
- package/dist/threads.js.map +1 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +175 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +809 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +16 -8
- package/src/client.ts +0 -1745
- package/src/core.ts +0 -114
- package/src/discovery.ts +0 -275
- package/src/domains.ts +0 -213
- package/src/history.ts +0 -139
- package/src/http.ts +0 -852
- package/src/keys.ts +0 -642
- package/src/memory.ts +0 -34
- package/src/messages.ts +0 -409
- package/src/node.ts +0 -63
- package/src/resolve.ts +0 -39
- package/src/resources.ts +0 -745
- package/src/threads.ts +0 -409
- package/src/transport.ts +0 -211
- package/src/types.ts +0 -428
package/src/memory.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { Storage, Transaction } from './types.ts';
|
|
2
|
-
|
|
3
|
-
/** Process-local storage for embedded ephemeral nodes. It does not claim durability. */
|
|
4
|
-
export class MemoryStorage implements Storage {
|
|
5
|
-
readonly guarantee = 'binding-defined' as const;
|
|
6
|
-
private records = new Map<string, unknown>();
|
|
7
|
-
private pending: Promise<void> = Promise.resolve();
|
|
8
|
-
|
|
9
|
-
async transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
|
|
10
|
-
const before = this.pending;
|
|
11
|
-
let release!: () => void;
|
|
12
|
-
this.pending = new Promise<void>(resolve => { release = resolve; });
|
|
13
|
-
await before;
|
|
14
|
-
const changes = new Map<string, unknown>();
|
|
15
|
-
const deleted = new Set<string>();
|
|
16
|
-
const tx: Transaction = {
|
|
17
|
-
get: async <V>(key: string) => structuredClone(deleted.has(key) ? undefined : changes.has(key) ? changes.get(key) : this.records.get(key)) as V | undefined,
|
|
18
|
-
put: async <V>(key: string, value: V) => { changes.set(key, structuredClone(value)); deleted.delete(key); },
|
|
19
|
-
delete: async (key: string) => { changes.delete(key); deleted.add(key); },
|
|
20
|
-
scan: async <V>(prefix: string) => {
|
|
21
|
-
const records = new Map(this.records);
|
|
22
|
-
deleted.forEach(key => records.delete(key));
|
|
23
|
-
changes.forEach((value, key) => records.set(key, value));
|
|
24
|
-
return Array.from(records).filter(([key]) => key.startsWith(prefix)).map(([key, value]) => ({ key, value: structuredClone(value) as V }));
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
try {
|
|
28
|
-
const result = await work(tx);
|
|
29
|
-
deleted.forEach(key => this.records.delete(key));
|
|
30
|
-
changes.forEach((value, key) => this.records.set(key, value));
|
|
31
|
-
return structuredClone(result);
|
|
32
|
-
} finally { release(); }
|
|
33
|
-
}
|
|
34
|
-
}
|
package/src/messages.ts
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
import type { Attention, Auth, DeliveryPlan, Entry, Event, Failure, Grant, Json, LocalAttention, Member, MessageQuery, MessageRef, Mutation, Outbound, PageOptions, Receipt, ReplyPermit, SendRequest, Thread, ThreadRef, Transaction } from './types.ts';
|
|
2
|
-
import { admissionView, canonical, decimal, fail, fingerprint, key, OnlyneError, page, requireValue, same, type Context } from './core.ts';
|
|
3
|
-
import { canRead, getThread } from './threads.ts';
|
|
4
|
-
|
|
5
|
-
type Remembered = { digest: string; receipt?: Receipt; error?: Failure; retry_until: string };
|
|
6
|
-
type Binding = { digest: string; identity: string };
|
|
7
|
-
type Outcome = { receipt: Receipt } | { error: Failure };
|
|
8
|
-
|
|
9
|
-
/** Refusals this receiver records durably; retryable, internal and capacity failures roll back instead. */
|
|
10
|
-
const RECORDED_REFUSALS: Record<string, true> = {
|
|
11
|
-
invalid_request: true, forbidden: true, not_a_member: true, not_found: true,
|
|
12
|
-
id_conflict: true, revision_conflict: true, conflict: true, thread_closed: true, context_lost: true,
|
|
13
|
-
retry_window_expired: true, payload_too_large: true, unsupported_media_type: true,
|
|
14
|
-
unsupported_delivery: true, ambiguous_target: true, ambiguous_resource: true,
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/** Cursors and positions in this service are decimal strings; compare them as numbers, never as text. */
|
|
18
|
-
const compareOrdinal = (a: string, b: string): number => decimal(a) < decimal(b) ? -1 : decimal(a) > decimal(b) ? 1 : 0;
|
|
19
|
-
|
|
20
|
-
export class MessageService {
|
|
21
|
-
readonly #ctx: Context;
|
|
22
|
-
|
|
23
|
-
constructor(ctx: Context) {
|
|
24
|
-
this.#ctx = ctx;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async admit(auth: Auth, request: SendRequest): Promise<Receipt> {
|
|
28
|
-
const { message } = request;
|
|
29
|
-
if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
|
|
30
|
-
if (typeof message?.id !== 'string' || !message.id || typeof message.sender !== 'string' || typeof message.msg !== 'string' || typeof message.delivery_kind !== 'string') fail('invalid_request', 'A message needs id, sender, delivery_kind and msg.');
|
|
31
|
-
if (message.sender !== auth.actor && auth.sender !== message.sender) fail('forbidden', 'The caller cannot represent this author.');
|
|
32
|
-
if (request.target_endpoint !== this.#ctx.node) fail('forbidden', 'The target endpoint is not served here.');
|
|
33
|
-
if (!request.plan || !['none', 'async', 'await'].includes(request.plan.response) || !['existing', 'new'].includes(request.plan.execution)) fail('invalid_request', 'An executable delivery plan is required.');
|
|
34
|
-
if (!request.idempotency_key) fail('invalid_request', 'An idempotency key is required.');
|
|
35
|
-
if (message.meta?.relation && !message.meta.in_reply_to) fail('invalid_request', 'A relation requires an original message.');
|
|
36
|
-
const operationKey = key('message-key', auth.actor, request.idempotency_key);
|
|
37
|
-
const requestDigest = await fingerprint({ message, target_endpoint: request.target_endpoint, plan: request.plan, retry_until: request.retry_until });
|
|
38
|
-
const identity = key('message', message.sender, message.id);
|
|
39
|
-
const digest = await fingerprint({ message, target_endpoint: request.target_endpoint });
|
|
40
|
-
|
|
41
|
-
// Asked before the admission transaction opens: a host that answers from its own store
|
|
42
|
-
// would otherwise wait on this transaction and deadlock it. The refusal it produces is
|
|
43
|
-
// still recorded inside the transaction below.
|
|
44
|
-
const runtime = this.#ctx.options.plugins.runtime;
|
|
45
|
-
const supported = runtime === undefined || await runtime.supports(request.plan, message);
|
|
46
|
-
const outcome = await this.#ctx.storage.transaction(async (tx): Promise<Outcome> => {
|
|
47
|
-
const bound = await tx.get<Binding>(operationKey);
|
|
48
|
-
if (bound && (bound.digest !== requestDigest || bound.identity !== identity)) fail('id_conflict', 'The idempotency key is bound to another request.');
|
|
49
|
-
const old = await tx.get<Remembered>(identity);
|
|
50
|
-
if (old) {
|
|
51
|
-
this.#ctx.options.plugins.runtime?.trace?.({ event: 'inbound.duplicate', sender: message.sender, message: message.id });
|
|
52
|
-
// A different body never replaces the identity already recorded, accepted or refused.
|
|
53
|
-
if (old.digest !== digest) return { error: new OnlyneError('id_conflict', 'This message identity has different content.').error };
|
|
54
|
-
if (old.error) return { error: old.error };
|
|
55
|
-
if (old.receipt === undefined) return { error: new OnlyneError('retry_window_expired', 'Only the message tombstone remains.').error };
|
|
56
|
-
if (bound === undefined) await tx.put(operationKey, { digest: requestDigest, identity });
|
|
57
|
-
return { receipt: old.receipt };
|
|
58
|
-
}
|
|
59
|
-
try {
|
|
60
|
-
if (!supported) {
|
|
61
|
-
// The plan is the only thing the sender can change, so the refusal names it: a host
|
|
62
|
-
// that cannot hold an await or open a new context says so, instead of leaving the
|
|
63
|
-
// reader to guess which half of the plan it refused.
|
|
64
|
-
return await this.refuse(tx, identity, digest, request, new OnlyneError(
|
|
65
|
-
'unsupported_delivery',
|
|
66
|
-
`This host cannot deliver a ${request.plan.response} response with ${request.plan.execution} execution; nothing was stored.`,
|
|
67
|
-
{ plan: { response: request.plan.response, execution: request.plan.execution } },
|
|
68
|
-
));
|
|
69
|
-
}
|
|
70
|
-
this.#ctx.deadline(request.retry_until);
|
|
71
|
-
return await this.append(tx, auth, request, digest, requestDigest, identity, operationKey);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (error instanceof OnlyneError && RECORDED_REFUSALS[error.error.code] === true) return this.refuse(tx, identity, digest, request, error);
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
if ('error' in outcome) throw new OnlyneError(outcome.error.code, outcome.error.message, outcome.error.details);
|
|
78
|
-
this.#ctx.options.plugins.runtime?.notify?.({ event: 'inbound', thread: outcome.receipt.thread_ref, seq: outcome.receipt.seq, sender: message.sender });
|
|
79
|
-
this.#ctx.options.plugins.runtime?.notify?.({ event: 'work-pending', pending: true });
|
|
80
|
-
return requireValue(outcome.receipt);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Every rejecting check runs before the first write, so a refusal commits only its
|
|
85
|
-
* durable refusal record and never a half-appended entry. A mention, a closed thread,
|
|
86
|
-
* a missing membership or a policy refusal all leave the thread cursor untouched.
|
|
87
|
-
*
|
|
88
|
-
* A first contact the owner's inbound policy permits stages its thread, sender
|
|
89
|
-
* membership and grant notice, then writes them beside the entry and the receipt in
|
|
90
|
-
* this same transaction: one admission records all of them or none.
|
|
91
|
-
*/
|
|
92
|
-
private async append(tx: Transaction, auth: Auth, request: SendRequest, digest: string, requestDigest: string, identity: string, operationKey: string): Promise<Outcome> {
|
|
93
|
-
const { message } = request;
|
|
94
|
-
const original = message.meta?.in_reply_to;
|
|
95
|
-
let threadRef = message.meta?.thread;
|
|
96
|
-
let permit: ReplyPermit | undefined;
|
|
97
|
-
if (original !== undefined) {
|
|
98
|
-
permit = await tx.get<ReplyPermit>(key('permit', original.sender, original.id, message.sender));
|
|
99
|
-
if (threadRef === undefined && permit && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.#ctx.now()
|
|
100
|
-
&& (message.meta?.relation !== 'followup' || permit.followup) && this.#ctx.node === permit.landing.owner) threadRef = permit.landing;
|
|
101
|
-
if (threadRef === undefined) threadRef = await this.chainLanding(tx, original, message.sender);
|
|
102
|
-
}
|
|
103
|
-
let firstContact = false;
|
|
104
|
-
if (threadRef === undefined) {
|
|
105
|
-
// The default landing is the owner's own slot, resolved once inside this transaction.
|
|
106
|
-
const landing = await tx.get<ThreadRef>(key('default', this.#ctx.node));
|
|
107
|
-
if (landing === undefined || landing.owner !== this.#ctx.node || typeof landing.id !== 'string' || landing.id.length === 0) {
|
|
108
|
-
// Finding a landing grants nothing (RFC 0002 §3): only the owner's inbound policy
|
|
109
|
-
// may open a thread and admit this sender into it.
|
|
110
|
-
const policy = this.#ctx.options.plugins.inboundThread;
|
|
111
|
-
if (policy === undefined || !await policy(auth.actor, admissionView(tx))) {
|
|
112
|
-
return this.refuse(tx, identity, digest, request, new OnlyneError('not_found', 'No default thread is configured.'));
|
|
113
|
-
}
|
|
114
|
-
firstContact = true;
|
|
115
|
-
let id = crypto.randomUUID();
|
|
116
|
-
while (await tx.get<Thread>(key('thread', this.#ctx.node, id))) id = crypto.randomUUID();
|
|
117
|
-
threadRef = { owner: this.#ctx.node, id };
|
|
118
|
-
} else {
|
|
119
|
-
threadRef = { owner: landing.owner, id: landing.id };
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
this.#ctx.owner(threadRef.owner);
|
|
123
|
-
const thread: Thread = firstContact
|
|
124
|
-
? { thread_ref: threadRef, title: message.msg.replace(/\s+/g, ' ').trim().slice(0, 100) || message.delivery_kind, state: 'open', next_seq: '1', event_seq: '0', created_at: new Date(this.#ctx.now()).toISOString(), closed_at: null }
|
|
125
|
-
: await getThread(tx, this.#ctx, threadRef);
|
|
126
|
-
const member = firstContact ? undefined : await tx.get<Member>(key('member', threadRef.owner, threadRef.id, message.sender));
|
|
127
|
-
const ordinary = firstContact || message.sender === threadRef.owner || (member?.state === 'active' && member.access.includes('post'));
|
|
128
|
-
const allowedPermit = permit !== undefined && !permit.revoked && permit.remaining > 0 && Date.parse(permit.expires_at) > this.#ctx.now()
|
|
129
|
-
&& same(permit.landing, threadRef) && (message.meta?.relation !== 'followup' || permit.followup);
|
|
130
|
-
if (!ordinary && !allowedPermit) return this.refuse(tx, identity, digest, request, new OnlyneError('not_a_member', 'The sender is not an active post member.'));
|
|
131
|
-
if (thread.state === 'closed') return this.refuse(tx, identity, digest, request, new OnlyneError('thread_closed', 'The thread is closed.'));
|
|
132
|
-
const admissionPolicy = this.#ctx.options.plugins.mayAdmit;
|
|
133
|
-
if (admissionPolicy && !await admissionPolicy(auth.actor, threadRef, admissionView(tx))) {
|
|
134
|
-
return this.refuse(tx, identity, digest, request, new OnlyneError('forbidden', 'The admission policy refused the request.'));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const members = (await tx.scan<Member>(key('member', threadRef.owner, threadRef.id) + '/')).map(row => row.value).filter(m => m.state === 'active');
|
|
138
|
-
const recipients = new Set<string>();
|
|
139
|
-
// A direct first contact is the owner's business even without a mention.
|
|
140
|
-
if (firstContact) recipients.add(threadRef.owner);
|
|
141
|
-
for (const mention of message.meta?.mentions ?? []) {
|
|
142
|
-
if (typeof mention !== 'string' || mention.length === 0) fail('invalid_request', 'A mention must be a non-empty string.');
|
|
143
|
-
if (mention === '@all') {
|
|
144
|
-
recipients.add(threadRef.owner);
|
|
145
|
-
members.forEach(m => recipients.add(m.peer));
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
const alias = mention.startsWith('@') ? mention.slice(1) : mention;
|
|
149
|
-
// The owner is addressable by its own node id without a membership record.
|
|
150
|
-
if (alias === threadRef.owner) { recipients.add(threadRef.owner); continue; }
|
|
151
|
-
if (members.some(m => m.peer === alias)) { recipients.add(alias); continue; }
|
|
152
|
-
// A display alias is not a node id; the integration layer owns the mapping.
|
|
153
|
-
const resolved = await this.#ctx.options.plugins.mention?.(alias, threadRef);
|
|
154
|
-
if (resolved === undefined || (resolved !== threadRef.owner && !members.some(m => m.peer === resolved))) {
|
|
155
|
-
fail('invalid_request', 'A mention must resolve to an active member identity.');
|
|
156
|
-
}
|
|
157
|
-
recipients.add(resolved);
|
|
158
|
-
}
|
|
159
|
-
if (original !== undefined) {
|
|
160
|
-
const source = await tx.get<Entry>(key('entry-ref', original.sender, original.id));
|
|
161
|
-
recipients.add(source ? source.reply_authority : original.sender);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (firstContact) {
|
|
165
|
-
// Written with the entry and the receipt in this one transaction: the thread, its
|
|
166
|
-
// sender membership and the grant notice either all commit or none of them do.
|
|
167
|
-
const revision = this.#ctx.id();
|
|
168
|
-
const membership: Member = { peer: message.sender, state: 'active', access: ['post', 'read'], read_scope: {}, revision };
|
|
169
|
-
const notice: Grant = { id: revision, recipient: message.sender, thread_ref: threadRef, revision, acked: false };
|
|
170
|
-
const eventId = (decimal(thread.event_seq) + 1n).toString();
|
|
171
|
-
thread.event_seq = eventId;
|
|
172
|
-
await tx.put(key('member', threadRef.owner, threadRef.id, message.sender), membership);
|
|
173
|
-
await tx.put(key('notice', notice.id), notice);
|
|
174
|
-
await tx.put(key('event', threadRef.owner, threadRef.id, eventId), { id: eventId, type: 'membership', thread_ref: threadRef, data: JSON.parse(JSON.stringify(membership)) as Json } satisfies Event);
|
|
175
|
-
}
|
|
176
|
-
const seq = thread.next_seq;
|
|
177
|
-
thread.next_seq = (decimal(seq) + 1n).toString();
|
|
178
|
-
thread.event_seq = (decimal(thread.event_seq) + 1n).toString();
|
|
179
|
-
const entry: Entry = {
|
|
180
|
-
thread_ref: threadRef, seq, message: structuredClone(message), authenticated_author: message.sender,
|
|
181
|
-
reply_authority: auth.reply_authority ?? auth.actor, at: new Date(this.#ctx.now()).toISOString(),
|
|
182
|
-
};
|
|
183
|
-
const receipt: Receipt = {
|
|
184
|
-
message_ref: { sender: message.sender, id: message.id }, receipt_id: this.#ctx.id(),
|
|
185
|
-
guarantee: this.#ctx.storage.guarantee, thread_ref: threadRef, seq, plan: structuredClone(request.plan),
|
|
186
|
-
thread_created: firstContact, previous_seq: (decimal(seq) - 1n).toString(),
|
|
187
|
-
// The answer a message gives is part of what the owner admitted: the sender can only
|
|
188
|
-
// prove "I replied to that one" from the receipt, and the reference here is the pair
|
|
189
|
-
// both sides' records agree on (cyanea report 2, problem 2).
|
|
190
|
-
...(message.meta?.in_reply_to === undefined ? {} : { in_reply_to: message.meta.in_reply_to }),
|
|
191
|
-
};
|
|
192
|
-
await tx.put(key('thread', threadRef.owner, threadRef.id), thread);
|
|
193
|
-
await tx.put(key('entry', threadRef.owner, threadRef.id, seq), entry);
|
|
194
|
-
await tx.put(key('entry-ref', message.sender, message.id), entry);
|
|
195
|
-
await tx.put<Remembered>(identity, { digest, receipt, retry_until: request.retry_until });
|
|
196
|
-
await tx.put(operationKey, { digest: requestDigest, identity });
|
|
197
|
-
if (permit !== undefined && original !== undefined && !ordinary && allowedPermit) {
|
|
198
|
-
permit.remaining--;
|
|
199
|
-
await tx.put(key('permit', original.sender, original.id, message.sender), permit);
|
|
200
|
-
}
|
|
201
|
-
for (const recipient of recipients) {
|
|
202
|
-
const obligation_id = key(threadRef.owner, threadRef.id, message.sender, message.id, recipient);
|
|
203
|
-
await this.writeAttention(tx, {
|
|
204
|
-
obligation_id, recipient, thread_ref: threadRef, seq,
|
|
205
|
-
message_ref: { sender: message.sender, id: message.id }, authenticated_author: message.sender, acked: false, entry,
|
|
206
|
-
}, request.plan);
|
|
207
|
-
}
|
|
208
|
-
const event: Event = { id: thread.event_seq, type: 'message', thread_ref: threadRef, message_ref: receipt.message_ref, data: { seq } };
|
|
209
|
-
await tx.put(key('event', threadRef.owner, threadRef.id, event.id), event);
|
|
210
|
-
return { receipt };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* The thread a reply belongs to, read from this node's own records. When the original message
|
|
215
|
-
* was delivered here, its entry names the thread. When this node sent it, there is no entry —
|
|
216
|
-
* the conversation lives in the nearest incoming message the chain reaches, which is what both
|
|
217
|
-
* nodes can read. Every hop is local; the walk stops at the first missing record.
|
|
218
|
-
*/
|
|
219
|
-
private async chainLanding(tx: Transaction, original: MessageRef, sender: string): Promise<ThreadRef | undefined> {
|
|
220
|
-
let ref: MessageRef | undefined = original;
|
|
221
|
-
const visited = new Set<string>();
|
|
222
|
-
while (ref !== undefined) {
|
|
223
|
-
const id = key(ref.sender, ref.id);
|
|
224
|
-
if (visited.has(id)) fail('invalid_request', 'The reply chain contains a cycle.');
|
|
225
|
-
visited.add(id);
|
|
226
|
-
const entry = await tx.get<Entry>(key('entry-ref', ref.sender, ref.id));
|
|
227
|
-
if (entry !== undefined) {
|
|
228
|
-
const landing = entry.thread_ref;
|
|
229
|
-
this.#ctx.owner(landing.owner);
|
|
230
|
-
const thread = await tx.get<Thread>(key('thread', landing.owner, landing.id));
|
|
231
|
-
if (thread === undefined) fail('not_found', 'The original reply landing is no longer available.');
|
|
232
|
-
if (thread.state === 'closed') fail('thread_closed', 'The original reply landing is closed; an explicitly authorized alternative is required.');
|
|
233
|
-
return landing;
|
|
234
|
-
}
|
|
235
|
-
const sent: Outbound | undefined = await tx.get<Outbound>(key('outbound', ref.sender, ref.id));
|
|
236
|
-
if (sent === undefined) fail('not_found', 'The original message history is unavailable; no alternative reply landing was authorized.');
|
|
237
|
-
if (ref.sender !== this.#ctx.node || sent.expected_author !== sender) fail('forbidden', 'This sender is not the expected reply author.');
|
|
238
|
-
const parent: MessageRef | undefined = sent.request.message.meta?.in_reply_to;
|
|
239
|
-
// The first reply to an outbound root has no local incoming thread yet.
|
|
240
|
-
if (parent === undefined) return undefined;
|
|
241
|
-
ref = parent;
|
|
242
|
-
}
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Terminal refusals are the receiver's record too: written through the admission transaction. */
|
|
247
|
-
private async refuse(tx: Transaction, identity: string, digest: string, request: SendRequest, error: OnlyneError): Promise<Outcome> {
|
|
248
|
-
const outcome = error.error;
|
|
249
|
-
await tx.put<Remembered>(identity, { digest, error: outcome, retry_until: request.retry_until });
|
|
250
|
-
return { error: outcome };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private async writeAttention(tx: Transaction, attention: Attention, plan: DeliveryPlan) {
|
|
254
|
-
const attested: Attention = { ...attention, plan };
|
|
255
|
-
await tx.put(key('attention', attested.thread_ref.owner, attested.thread_ref.id, attested.obligation_id), attested);
|
|
256
|
-
if (attested.recipient !== this.#ctx.node) return;
|
|
257
|
-
const slot = key('inbox', attested.obligation_id);
|
|
258
|
-
if (await tx.get(slot) !== undefined) return;
|
|
259
|
-
await tx.put<LocalAttention>(slot, {
|
|
260
|
-
attention: attested,
|
|
261
|
-
...(plan.context === undefined ? {} : { context: plan.context }),
|
|
262
|
-
injected: false,
|
|
263
|
-
});
|
|
264
|
-
this.#ctx.options.plugins.runtime?.trace?.({
|
|
265
|
-
event: 'inbound.received', obligation: attested.obligation_id, thread: attested.thread_ref,
|
|
266
|
-
seq: attested.seq, sender: attested.message_ref.sender, valid: true, injected: false,
|
|
267
|
-
...(plan.context === undefined ? {} : { context: plan.context }),
|
|
268
|
-
...(attested.entry?.message.meta?.relation === 'reply' || attested.entry?.message.meta?.relation === 'followup'
|
|
269
|
-
? { relation: attested.entry.message.meta.relation } : {}),
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async list(auth: Auth, ref: ThreadRef, query: MessageQuery = {}) {
|
|
274
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
275
|
-
const thread = await getThread(tx, this.#ctx, ref);
|
|
276
|
-
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
|
|
277
|
-
const entries: Entry[] = [];
|
|
278
|
-
for (const { value: entry } of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) {
|
|
279
|
-
if (!await canRead(tx, auth.actor, ref, entry)) continue;
|
|
280
|
-
if (query.since_seq && decimal(entry.seq) <= decimal(query.since_seq)) continue;
|
|
281
|
-
if (query.peer && entry.authenticated_author !== query.peer) continue;
|
|
282
|
-
if (query.related_to && !same(entry.message.meta?.in_reply_to, query.related_to)) continue;
|
|
283
|
-
if ((query.attention ?? 'inbox') === 'inbox') {
|
|
284
|
-
const rows = await tx.scan<Attention>(key('attention', ref.owner, ref.id) + '/');
|
|
285
|
-
if (!rows.some(a => a.value.recipient === auth.actor && same(a.value.message_ref, { sender: entry.message.sender, id: entry.message.id }))) continue;
|
|
286
|
-
}
|
|
287
|
-
entries.push(entry);
|
|
288
|
-
}
|
|
289
|
-
entries.sort((a, b) => compareOrdinal(a.seq, b.seq));
|
|
290
|
-
return { ...page(entries, query, { ref, actor: auth.actor, peer: query.peer, related_to: query.related_to, attention: query.attention ?? 'inbox', since_seq: query.since_seq }, e => e.seq, compareOrdinal), retention: thread.messages_until ?? null };
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* The owner-attested obligation list for the caller. A reader whose membership no
|
|
296
|
-
* longer admits the body gets the tombstone: the entry is stripped before the row
|
|
297
|
-
* is stored back and before it is returned, so a revoked reader never sees content.
|
|
298
|
-
*/
|
|
299
|
-
async attention(auth: Auth, ref: ThreadRef, options: PageOptions = {}) {
|
|
300
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
301
|
-
await getThread(tx, this.#ctx, ref);
|
|
302
|
-
const allowed = await canRead(tx, auth.actor, ref);
|
|
303
|
-
const rows = (await tx.scan<Attention>(key('attention', ref.owner, ref.id) + '/')).map(r => r.value).filter(a => a.recipient === auth.actor && !a.acked);
|
|
304
|
-
const visible: Attention[] = [];
|
|
305
|
-
for (const row of rows) {
|
|
306
|
-
if (row.disposition === 'expired') { visible.push(row); continue; }
|
|
307
|
-
if (!allowed || row.entry === undefined || !await canRead(tx, auth.actor, ref, row.entry)) {
|
|
308
|
-
const stripped: Attention = { ...row, entry: undefined, disposition: 'expired' };
|
|
309
|
-
await tx.put(key('attention', ref.owner, ref.id, row.obligation_id), stripped);
|
|
310
|
-
visible.push(stripped);
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
visible.push(row);
|
|
314
|
-
}
|
|
315
|
-
visible.sort((a, b) => compareOrdinal(a.seq, b.seq));
|
|
316
|
-
return page(visible, options, { ref, actor: auth.actor, kind: 'attention' }, a => a.seq, compareOrdinal);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async ack(auth: Auth, ref: ThreadRef, id: string, mutation: Mutation) {
|
|
321
|
-
return this.#ctx.mutate(auth, `attention-ack/${canonical(ref)}/${id}`, mutation, {}, async tx => {
|
|
322
|
-
await getThread(tx, this.#ctx, ref);
|
|
323
|
-
const slot = key('attention', ref.owner, ref.id, id);
|
|
324
|
-
const attention = requireValue(await tx.get<Attention>(slot));
|
|
325
|
-
if (attention.recipient !== auth.actor) fail('not_found', 'This attention obligation is not addressed to the caller.');
|
|
326
|
-
attention.acked = true;
|
|
327
|
-
await tx.put(slot, attention);
|
|
328
|
-
const local = await tx.get<LocalAttention>(key('inbox', id));
|
|
329
|
-
if (local) { local.attention = attention; await tx.put(key('inbox', id), local); }
|
|
330
|
-
return { acked: true };
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async inbox(auth: Auth, options: PageOptions = {}) {
|
|
335
|
-
if (auth.actor !== this.#ctx.node) fail('forbidden', 'The local inbox belongs to this node.');
|
|
336
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
337
|
-
const rows = (await tx.scan<LocalAttention>('inbox/')).map(r => r.value);
|
|
338
|
-
rows.sort((a, b) => a.attention.obligation_id.localeCompare(b.attention.obligation_id));
|
|
339
|
-
const indexed = rows.map((value, index) => ({ value, position: String(index + 1) }));
|
|
340
|
-
const result = page(indexed, options, { actor: auth.actor, kind: 'local-inbox' }, r => r.position, compareOrdinal);
|
|
341
|
-
return { items: result.items.map(r => r.value), next_cursor: result.next_cursor };
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
async getByRef(auth: Auth, ref: MessageRef) {
|
|
346
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
347
|
-
const entry = requireValue(await tx.get<Entry>(key('entry-ref', ref.sender, ref.id)));
|
|
348
|
-
if (!await canRead(tx, auth.actor, entry.thread_ref, entry)) fail('not_found', 'This message is not readable.');
|
|
349
|
-
return entry;
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async events(auth: Auth, ref: ThreadRef, options: PageOptions = {}) {
|
|
354
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
355
|
-
await getThread(tx, this.#ctx, ref);
|
|
356
|
-
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
|
|
357
|
-
const rows: Event[] = [];
|
|
358
|
-
for (const { value: event } of await tx.scan<Event>(key('event', ref.owner, ref.id) + '/')) {
|
|
359
|
-
if (event.message_ref) {
|
|
360
|
-
const entry = await tx.get<Entry>(key('entry-ref', event.message_ref.sender, event.message_ref.id));
|
|
361
|
-
if (!entry || !await canRead(tx, auth.actor, ref, entry)) continue;
|
|
362
|
-
} else if (event.type === 'membership' && auth.actor !== this.#ctx.node) continue;
|
|
363
|
-
rows.push(event);
|
|
364
|
-
}
|
|
365
|
-
rows.sort((a, b) => compareOrdinal(a.id, b.id));
|
|
366
|
-
return page(rows, options, { ref, actor: auth.actor, kind: 'events' }, e => e.id, compareOrdinal);
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async snapshot(auth: Auth, ref: ThreadRef) {
|
|
371
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
372
|
-
const thread = await getThread(tx, this.#ctx, ref);
|
|
373
|
-
if (!await canRead(tx, auth.actor, ref)) fail('forbidden', 'Read access is required.');
|
|
374
|
-
const entries: Entry[] = [];
|
|
375
|
-
for (const row of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) if (await canRead(tx, auth.actor, ref, row.value)) entries.push(row.value);
|
|
376
|
-
entries.sort((a, b) => compareOrdinal(a.seq, b.seq));
|
|
377
|
-
return { thread, entries: entries.slice(0, 50), resume_cursor: encodeURIComponent(canonical({ scope: { ref, actor: auth.actor, kind: 'events' }, after: thread.event_seq })) };
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
async expire(auth: Auth, ref: ThreadRef, beforeSeq: string) {
|
|
382
|
-
this.#ctx.owner(ref.owner);
|
|
383
|
-
if (auth.actor !== this.#ctx.node) fail('forbidden', 'Only the owner may expire history.');
|
|
384
|
-
return this.#ctx.storage.transaction(async tx => {
|
|
385
|
-
await getThread(tx, this.#ctx, ref);
|
|
386
|
-
for (const row of await tx.scan<Entry>(key('entry', ref.owner, ref.id) + '/')) {
|
|
387
|
-
if (decimal(row.value.seq) >= decimal(beforeSeq)) continue;
|
|
388
|
-
const recordKey = key('message', row.value.message.sender, row.value.message.id);
|
|
389
|
-
const record = requireValue(await tx.get<Remembered>(recordKey));
|
|
390
|
-
if (Date.parse(record.retry_until) > this.#ctx.now()) fail('conflict', 'The replay window is still open.');
|
|
391
|
-
// The tombstone keeps the digest and the window, never the body.
|
|
392
|
-
await tx.put(recordKey, { digest: record.digest, retry_until: record.retry_until });
|
|
393
|
-
await tx.delete(row.key);
|
|
394
|
-
await tx.delete(key('entry-ref', row.value.message.sender, row.value.message.id));
|
|
395
|
-
const identity = { sender: row.value.message.sender, id: row.value.message.id };
|
|
396
|
-
for (const attention of await tx.scan<Attention>(key('attention', ref.owner, ref.id) + '/')) {
|
|
397
|
-
if (!same(attention.value.message_ref, identity)) continue;
|
|
398
|
-
if (attention.value.disposition === 'expired') continue;
|
|
399
|
-
const stripped: Attention = { ...attention.value, entry: undefined, disposition: 'expired' };
|
|
400
|
-
await tx.put(attention.key, stripped);
|
|
401
|
-
const local = await tx.get<LocalAttention>(key('inbox', attention.value.obligation_id));
|
|
402
|
-
if (local) { local.attention = stripped; await tx.put(key('inbox', attention.value.obligation_id), local); }
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
return { expired_before: beforeSeq };
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
}
|
package/src/node.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { Auth, NodeOptions } from './types.ts';
|
|
2
|
-
import { createContext } from './core.ts';
|
|
3
|
-
import { ThreadService } from './threads.ts';
|
|
4
|
-
import { MessageService } from './messages.ts';
|
|
5
|
-
import { createResourceService } from './resources.ts';
|
|
6
|
-
import type { ResourceService } from './resources.ts';
|
|
7
|
-
import { DomainService } from './domains.ts';
|
|
8
|
-
import { DiscoveryService } from './discovery.ts';
|
|
9
|
-
import { OnlyneClient } from './client.ts';
|
|
10
|
-
import { KeyService, TrustService } from './keys.ts';
|
|
11
|
-
import { FetchTransport } from './transport.ts';
|
|
12
|
-
import { composeResolver } from './resolve.ts';
|
|
13
|
-
import { createHttpHandler } from './http.ts';
|
|
14
|
-
import { HistoryService } from './history.ts';
|
|
15
|
-
|
|
16
|
-
export class OnlyneNode {
|
|
17
|
-
readonly threads: ThreadService;
|
|
18
|
-
readonly messages: MessageService;
|
|
19
|
-
readonly history: HistoryService;
|
|
20
|
-
readonly resources: ResourceService;
|
|
21
|
-
readonly domains: DomainService;
|
|
22
|
-
readonly discovery: DiscoveryService;
|
|
23
|
-
readonly keys: KeyService;
|
|
24
|
-
readonly trust: TrustService;
|
|
25
|
-
readonly client: OnlyneClient;
|
|
26
|
-
/** The transport this node signs with; a host that talks to peers itself must reuse it. */
|
|
27
|
-
readonly transport: import('./types.ts').Transport;
|
|
28
|
-
readonly fetch: (request: Request) => Promise<Response>;
|
|
29
|
-
readonly auth: Auth;
|
|
30
|
-
|
|
31
|
-
constructor(options: NodeOptions) {
|
|
32
|
-
const ctx = createContext(options);
|
|
33
|
-
this.auth = { actor: options.node_id };
|
|
34
|
-
this.threads = new ThreadService(ctx);
|
|
35
|
-
this.messages = new MessageService(ctx);
|
|
36
|
-
this.history = new HistoryService(ctx);
|
|
37
|
-
this.keys = new KeyService(ctx);
|
|
38
|
-
this.trust = new TrustService(ctx, this.keys);
|
|
39
|
-
this.discovery = new DiscoveryService(ctx);
|
|
40
|
-
const transport = options.plugins.transport ?? new FetchTransport(
|
|
41
|
-
// The composed resolver is the one address authority (resolve.ts): discovery first,
|
|
42
|
-
// the trust row as the fallback that writes a moved peer's address back.
|
|
43
|
-
composeResolver({
|
|
44
|
-
resolve: options.plugins.resolve,
|
|
45
|
-
trust: this.trust,
|
|
46
|
-
observe: (base_url, source, expectedNode) => this.discovery.observe(base_url, source, expectedNode),
|
|
47
|
-
}),
|
|
48
|
-
fetch,
|
|
49
|
-
options.plugins.keys === undefined ? undefined : { trust: this.trust },
|
|
50
|
-
);
|
|
51
|
-
// A handover probe is a signed peer request, so the resource service shares the node's
|
|
52
|
-
// transport instead of reaching the network on its own.
|
|
53
|
-
this.resources = createResourceService(ctx, transport);
|
|
54
|
-
this.domains = new DomainService(ctx);
|
|
55
|
-
this.transport = transport;
|
|
56
|
-
this.client = new OnlyneClient(ctx, transport, this.messages, this.threads, this.resources, this.domains, this.trust);
|
|
57
|
-
this.trust.bindLoop(this.client);
|
|
58
|
-
this.fetch = createHttpHandler({
|
|
59
|
-
ctx, threads: this.threads, messages: this.messages, resources: this.resources, domains: this.domains, discovery: this.discovery,
|
|
60
|
-
keys: this.keys, trust: this.trust,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
package/src/resolve.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { OnlyneError } from './core.ts';
|
|
2
|
-
import type { Failure, NodeId, Target, TrustEntry } from './types.ts';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The one place an address is composed for the wire (RFC 0011 §4, RFC 0018 §5.1).
|
|
6
|
-
* The host resolver — usually the discovery catalog — answers first. When it reports
|
|
7
|
-
* `not_found` (never observed) or `unavailable` (recorded but no address answers), a
|
|
8
|
-
* trusted row's `base_url` is the authoritative fallback: probing it succeeds writes the
|
|
9
|
-
* address back into the discovery catalog, so a peer that moved stays reachable and the
|
|
10
|
-
* new address becomes visible to the model. A trusted row whose address also fails is
|
|
11
|
-
* `unavailable` — never degraded to `not_found`, which names an unknown identity.
|
|
12
|
-
*/
|
|
13
|
-
export function composeResolver(deps: {
|
|
14
|
-
resolve: (node: NodeId) => Promise<Target>;
|
|
15
|
-
trust?: { entry(node: NodeId): Promise<TrustEntry | undefined> };
|
|
16
|
-
observe?: (base_url: string, source: string, expectedNode?: NodeId) => Promise<unknown>;
|
|
17
|
-
}): (node: NodeId) => Promise<Target> {
|
|
18
|
-
return async node => {
|
|
19
|
-
try {
|
|
20
|
-
return await deps.resolve(node);
|
|
21
|
-
} catch (error) {
|
|
22
|
-
const failure: Failure | undefined = error instanceof OnlyneError
|
|
23
|
-
? error.error
|
|
24
|
-
: (error as { error?: Failure } | null)?.error;
|
|
25
|
-
if (failure?.code !== 'not_found' && failure?.code !== 'unavailable') throw error;
|
|
26
|
-
const entry = await deps.trust?.entry(node);
|
|
27
|
-
if (entry === undefined || entry.state !== 'trusted' || entry.base_url.length === 0) throw error;
|
|
28
|
-
if (deps.observe !== undefined) {
|
|
29
|
-
try {
|
|
30
|
-
await deps.observe(entry.base_url, 'trust-row', node);
|
|
31
|
-
} catch {
|
|
32
|
-
throw new OnlyneError('unavailable',
|
|
33
|
-
`${node} holds a trusted row whose address is ${entry.base_url}, but no recorded address answered and that address did not verify either.`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return { node_id: node, base_url: entry.base_url };
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
}
|