@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.
Files changed (121) hide show
  1. package/README.md +4 -3
  2. package/dist/awaited.d.ts +43 -0
  3. package/dist/awaited.d.ts.map +1 -0
  4. package/dist/awaited.js +82 -0
  5. package/dist/awaited.js.map +1 -0
  6. package/dist/client.d.ts +140 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +1772 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/core.d.ts +36 -0
  11. package/dist/core.d.ts.map +1 -0
  12. package/dist/core.js +122 -0
  13. package/dist/core.js.map +1 -0
  14. package/dist/delivery.d.ts +120 -0
  15. package/dist/delivery.d.ts.map +1 -0
  16. package/dist/delivery.js +117 -0
  17. package/dist/delivery.js.map +1 -0
  18. package/dist/discovery.d.ts +35 -0
  19. package/dist/discovery.d.ts.map +1 -0
  20. package/dist/discovery.js +306 -0
  21. package/dist/discovery.js.map +1 -0
  22. package/dist/domains.d.ts +29 -0
  23. package/dist/domains.d.ts.map +1 -0
  24. package/dist/domains.js +267 -0
  25. package/dist/domains.js.map +1 -0
  26. package/dist/history.d.ts +13 -0
  27. package/dist/history.d.ts.map +1 -0
  28. package/dist/history.js +143 -0
  29. package/dist/history.js.map +1 -0
  30. package/dist/http.d.ts +20 -0
  31. package/dist/http.d.ts.map +1 -0
  32. package/dist/http.js +88 -0
  33. package/dist/http.js.map +1 -0
  34. package/{src/index.ts → dist/index.d.ts} +2 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +15 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/keys.d.ts +97 -0
  39. package/dist/keys.d.ts.map +1 -0
  40. package/dist/keys.js +679 -0
  41. package/dist/keys.js.map +1 -0
  42. package/dist/memory.d.ts +9 -0
  43. package/dist/memory.d.ts.map +1 -0
  44. package/dist/memory.js +35 -0
  45. package/dist/memory.js.map +1 -0
  46. package/dist/messages.d.ts +64 -0
  47. package/dist/messages.d.ts.map +1 -0
  48. package/dist/messages.js +480 -0
  49. package/dist/messages.js.map +1 -0
  50. package/dist/node.d.ts +26 -0
  51. package/dist/node.d.ts.map +1 -0
  52. package/dist/node.js +59 -0
  53. package/dist/node.js.map +1 -0
  54. package/dist/resolve.d.ts +18 -0
  55. package/dist/resolve.d.ts.map +1 -0
  56. package/dist/resolve.js +37 -0
  57. package/dist/resolve.js.map +1 -0
  58. package/dist/resources.d.ts +25 -0
  59. package/dist/resources.d.ts.map +1 -0
  60. package/dist/resources.js +782 -0
  61. package/dist/resources.js.map +1 -0
  62. package/dist/routes/domains.d.ts +4 -0
  63. package/dist/routes/domains.d.ts.map +1 -0
  64. package/dist/routes/domains.js +42 -0
  65. package/dist/routes/domains.js.map +1 -0
  66. package/dist/routes/grants.d.ts +8 -0
  67. package/dist/routes/grants.d.ts.map +1 -0
  68. package/dist/routes/grants.js +32 -0
  69. package/dist/routes/grants.js.map +1 -0
  70. package/dist/routes/public.d.ts +4 -0
  71. package/dist/routes/public.d.ts.map +1 -0
  72. package/dist/routes/public.js +62 -0
  73. package/dist/routes/public.js.map +1 -0
  74. package/dist/routes/resources.d.ts +8 -0
  75. package/dist/routes/resources.d.ts.map +1 -0
  76. package/dist/routes/resources.js +87 -0
  77. package/dist/routes/resources.js.map +1 -0
  78. package/dist/routes/shared.d.ts +77 -0
  79. package/dist/routes/shared.d.ts.map +1 -0
  80. package/dist/routes/shared.js +349 -0
  81. package/dist/routes/shared.js.map +1 -0
  82. package/dist/routes/threads.d.ts +8 -0
  83. package/dist/routes/threads.d.ts.map +1 -0
  84. package/dist/routes/threads.js +113 -0
  85. package/dist/routes/threads.js.map +1 -0
  86. package/dist/routes/trust.d.ts +4 -0
  87. package/dist/routes/trust.d.ts.map +1 -0
  88. package/dist/routes/trust.js +36 -0
  89. package/dist/routes/trust.js.map +1 -0
  90. package/dist/stream.d.ts +20 -0
  91. package/dist/stream.d.ts.map +1 -0
  92. package/dist/stream.js +141 -0
  93. package/dist/stream.js.map +1 -0
  94. package/dist/threads.d.ts +79 -0
  95. package/dist/threads.d.ts.map +1 -0
  96. package/dist/threads.js +480 -0
  97. package/dist/threads.js.map +1 -0
  98. package/dist/transport.d.ts +45 -0
  99. package/dist/transport.d.ts.map +1 -0
  100. package/dist/transport.js +175 -0
  101. package/dist/transport.js.map +1 -0
  102. package/dist/types.d.ts +809 -0
  103. package/dist/types.d.ts.map +1 -0
  104. package/dist/types.js +9 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +16 -8
  107. package/src/client.ts +0 -1745
  108. package/src/core.ts +0 -114
  109. package/src/discovery.ts +0 -275
  110. package/src/domains.ts +0 -213
  111. package/src/history.ts +0 -139
  112. package/src/http.ts +0 -852
  113. package/src/keys.ts +0 -642
  114. package/src/memory.ts +0 -34
  115. package/src/messages.ts +0 -409
  116. package/src/node.ts +0 -63
  117. package/src/resolve.ts +0 -39
  118. package/src/resources.ts +0 -745
  119. package/src/threads.ts +0 -409
  120. package/src/transport.ts +0 -211
  121. package/src/types.ts +0 -428
package/src/core.ts DELETED
@@ -1,114 +0,0 @@
1
- import type { AdmissionContext, Auth, ErrorCode, Failure, Mutation, NodeId, NodeOptions, Page, PageOptions, Storage, Transaction, TrustEntry } from './types.ts';
2
- import { trustPhase } from './types.ts';
3
-
4
- const statuses: Record<ErrorCode, number> = {
5
- invalid_request: 400, unauthenticated: 401, forbidden: 403, not_a_member: 403,
6
- not_found: 404, id_conflict: 409, revision_conflict: 409, request_in_progress: 409,
7
- conflict: 409, thread_closed: 410, context_lost: 410, retry_window_expired: 410, cursor_expired: 410,
8
- payload_too_large: 413, unsupported_media_type: 415, unsupported_delivery: 422,
9
- ambiguous_target: 422, ambiguous_resource: 422, capacity_exceeded: 429,
10
- internal: 500, unavailable: 503, decision_unavailable: 503,
11
- };
12
- export class OnlyneError extends Error {
13
- readonly status: number;
14
- readonly error: Failure;
15
- constructor(code: ErrorCode, message: string, details?: Failure['details']) {
16
- super(message);
17
- this.status = statuses[code];
18
- this.error = { code, message, retryable: code === 'unavailable' || code === 'decision_unavailable' || code === 'request_in_progress', ...(details === undefined ? {} : { details }) };
19
- }
20
- }
21
- export function fail(code: ErrorCode, message: string, details?: Failure['details']): never { throw new OnlyneError(code, message, details); }
22
- export function requireValue<T>(value: T | undefined | null, code: ErrorCode = 'not_found'): T {
23
- if (value == null) fail(code, 'The requested record is unavailable.');
24
- return value;
25
- }
26
- export function key(...parts: string[]): string { return parts.map(encodeURIComponent).join('/'); }
27
- /**
28
- * The durable facts an admission policy decides from, bound to the transaction that is admitting
29
- * the message (RFC 0017 §4). A policy that opens its own storage transaction deadlocks: the
30
- * admission transaction still holds the store.
31
- */
32
- export function admissionView(tx: Transaction): AdmissionContext {
33
- return {
34
- async trust(node: NodeId) {
35
- const entry = await tx.get<TrustEntry>(key('trust', node));
36
- return entry === undefined ? undefined : trustPhase(entry);
37
- },
38
- };
39
- }
40
- export function canonical(value: unknown): string {
41
- if (value === undefined) return 'null';
42
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
43
- if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
44
- const record = value as Record<string, unknown>;
45
- return '{' + Object.keys(record).filter(k => record[k] !== undefined).sort().map(k => JSON.stringify(k) + ':' + canonical(record[k])).join(',') + '}';
46
- }
47
- export function same(a: unknown, b: unknown): boolean { return canonical(a) === canonical(b); }
48
-
49
- /** Stable one-way digest of a canonical form. Tombstones keep digests, never content. */
50
- export async function fingerprint(value: unknown): Promise<string> {
51
- const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical(value)));
52
- return Array.from(new Uint8Array(bytes)).map(b => b.toString(16).padStart(2, '0')).join('');
53
- }
54
- export function decimal(value: string): bigint {
55
- if (!/^(0|[1-9][0-9]*)$/.test(value)) fail('invalid_request', 'Expected a decimal sequence string.');
56
- return BigInt(value);
57
- }
58
- export function utc(value: string): number {
59
- if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT.*Z$/.test(value) || !Number.isFinite(Date.parse(value))) fail('invalid_request', 'Expected a UTC RFC3339 deadline.');
60
- return Date.parse(value);
61
- }
62
- export function page<T>(items: T[], options: PageOptions, scope: unknown, position: (item: T) => string, compare: (a: string, b: string) => number = (a, b) => a < b ? -1 : a > b ? 1 : 0): Page<T> {
63
- const size = options.page_size ?? 50;
64
- if (!Number.isInteger(size) || size < 1 || size > 100) fail('invalid_request', 'page_size must be from 1 to 100.');
65
- let after: string | undefined;
66
- if (options.cursor) {
67
- let cursor: { scope: unknown; after: string } | null;
68
- try { cursor = JSON.parse(decodeURIComponent(options.cursor)); } catch { return fail('invalid_request', 'Invalid cursor.'); }
69
- if (cursor === null || typeof cursor !== 'object' || !same(cursor.scope, scope) || typeof cursor.after !== 'string') fail('invalid_request', 'Cursor belongs to another query.');
70
- after = cursor.after;
71
- }
72
- const remaining = after === undefined ? items : items.filter(item => compare(position(item), after!) > 0);
73
- const selected = remaining.slice(0, size);
74
- return { items: selected, next_cursor: remaining.length > size ? encodeURIComponent(canonical({ scope, after: position(selected[selected.length - 1]!) })) : null };
75
- }
76
- export interface Context {
77
- node: string; baseUrl: string; instanceId: string; storage: Storage; options: NodeOptions;
78
- now(): number; id(): string;
79
- owner(owner: string): void;
80
- deadline(retry_until: string): void;
81
- mutate<T>(auth: Auth, operation: string, mutation: Mutation, body: unknown, work: (tx: Transaction) => Promise<T>): Promise<T>;
82
- }
83
-
84
-
85
- export function createContext(options: NodeOptions): Context {
86
- const ctx: Context = {
87
- node: options.node_id, baseUrl: options.base_url, instanceId: options.instance_id ?? crypto.randomUUID(), storage: options.plugins.storage, options,
88
- now: options.now, id: options.id,
89
- owner(owner) { if (owner !== options.node_id) fail('forbidden', 'This node does not serve the requested owner.'); },
90
- deadline(until) {
91
- const time = utc(until);
92
- if (time <= ctx.now()) fail('retry_window_expired', 'The admission window expired.');
93
- if (time > ctx.now() + (options.retry_max_ms ?? 86_400_000)) fail('invalid_request', 'The admission window exceeds the supported maximum.');
94
- },
95
- async mutate(auth, operation, mutation, body, work) {
96
- if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
97
- if (!mutation.idempotency_key) fail('invalid_request', 'An idempotency key is required.');
98
- const slot = key('idempotency', auth.actor, operation, mutation.idempotency_key);
99
- const digest = await fingerprint({ body, retry_until: mutation.retry_until });
100
- return ctx.storage.transaction(async tx => {
101
- const previous = await tx.get<{ digest: string; result: unknown }>(slot);
102
- if (previous) {
103
- if (previous.digest !== digest) fail('id_conflict', 'The idempotency key is bound to another request.');
104
- return previous.result as never;
105
- }
106
- ctx.deadline(mutation.retry_until);
107
- const result = await work(tx);
108
- await tx.put(slot, { digest, result });
109
- return result;
110
- });
111
- },
112
- };
113
- return ctx;
114
- }
package/src/discovery.ts DELETED
@@ -1,275 +0,0 @@
1
- import { OnlyneError, fail, key, page, type Context } from './core.ts';
2
- import { membershipRecord, validateDomainId } from './domains.ts';
3
- import type {
4
- DiscoveryCandidate, DiscoveryRecord, DomainId, Failure, NodeId, Page, PageOptions, ServiceDescription, Target,
5
- } from './types.ts';
6
-
7
- /** How long candidate evidence stays current. An expired record is history, not a live address. */
8
- const FRESH_MS = 120_000;
9
- const PREFIX = 'discovery';
10
-
11
- function iso(at: number): string { return new Date(at).toISOString(); }
12
-
13
- function order(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; }
14
-
15
- function position(candidate: DiscoveryCandidate): string {
16
- return `${candidate.node_id}\u0000${candidate.instance_id}\u0000${candidate.base_url}`;
17
- }
18
-
19
- function hostOf(url: URL): string { return url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); }
20
-
21
- /** Loopback may be spoken to over plain HTTP; every other peer must use HTTPS. */
22
- function loopback(url: URL): boolean {
23
- const host = hostOf(url);
24
- return host === 'localhost' || host === '::1' || /^127\./.test(host);
25
- }
26
-
27
- /** True for an address another host could use. Loopback, wildcard and unscoped link-local stay home. */
28
- function exchangeable(value: string, allowLoopback = false): boolean {
29
- let url: URL;
30
- try { url = new URL(value); } catch { return false; }
31
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
32
- const host = hostOf(url);
33
- if ((!allowLoopback && loopback(url)) || host === '0.0.0.0' || host === '::') return false;
34
- if (/^169\.254\./.test(host)) return false;
35
- return !(/^fe80:/.test(host) && !host.includes('%'));
36
- }
37
-
38
- function address(value: string): URL {
39
- let url: URL | undefined;
40
- try { url = new URL(value); } catch { /* reported below */ }
41
- if (url === undefined) fail('invalid_request', 'A discovery address must be an absolute URL.');
42
- if (url.protocol !== 'http:' && url.protocol !== 'https:') fail('invalid_request', 'A discovery address must be HTTP or HTTPS.');
43
- if (url.search !== '' || url.hash !== '') fail('invalid_request', 'A discovery address carries no query or fragment.');
44
- return url;
45
- }
46
-
47
- function field(value: unknown, name: string): string {
48
- if (typeof value !== 'string' || value.length === 0) fail('unavailable', `The candidate published no ${name}.`);
49
- return value;
50
- }
51
-
52
- function isFailureRecord(value: unknown): value is Failure {
53
- if (value === null || typeof value !== 'object' || !('code' in value) || !('message' in value)) return false;
54
- return typeof value.code === 'string' && typeof value.message === 'string';
55
- }
56
-
57
- function names(value: unknown, what: string): string[] {
58
- if (!Array.isArray(value) || !value.every(entry => typeof entry === 'string')) fail('unavailable', `The candidate published unreadable ${what}.`);
59
- return value;
60
- }
61
-
62
- /**
63
- * A service description is the candidate's own declaration. It must speak onlyne/3, name an
64
- * instance, and publish a membership that agrees with the identity it claims.
65
- */
66
- function published(value: unknown): ServiceDescription {
67
- if (value === null || typeof value !== 'object' || Array.isArray(value)) fail('unavailable', 'The candidate returned no service description.');
68
- if (!('protocol' in value) || !('node_id' in value) || !('instance_id' in value) || !('base_url' in value)
69
- || !('features' in value) || !('authentication' in value) || !('membership' in value) || !('domain' in value)) {
70
- fail('unavailable', 'The candidate returned an incomplete service description.');
71
- }
72
- if (value.protocol !== 'onlyne/3') fail('unavailable', 'The candidate does not speak onlyne/3.');
73
- const node_id = field(value.node_id, 'node identity');
74
- const instance_id = field(value.instance_id, 'instance identity');
75
- const base_url = field(value.base_url, 'base address');
76
- let parsed: URL | undefined;
77
- try { parsed = new URL(base_url); } catch { /* reported below */ }
78
- if (parsed === undefined) fail('unavailable', 'The candidate published an unreadable base address.');
79
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') fail('unavailable', 'The candidate published a base address this protocol cannot use.');
80
- const features = names(value.features, 'features');
81
- const authentication = names(value.authentication, 'authentication schemes');
82
- const membership = membershipRecord(value.membership);
83
- if (membership === undefined) fail('unavailable', 'The candidate published an unreadable membership.');
84
- if (membership !== null && membership.node_id !== node_id) fail('unavailable', 'The candidate published a membership for another node.');
85
- const domain = value.domain;
86
- if (domain !== null && typeof domain !== 'string') fail('unavailable', 'The candidate published an unreadable region.');
87
- if (domain !== null && (membership === null || membership.domain_id !== domain)) fail('unavailable', 'The candidate published a region its membership does not match.');
88
- return {
89
- protocol: 'onlyne/3', node_id, instance_id, base_url,
90
- features, authentication, domain, membership,
91
- };
92
- }
93
-
94
- /**
95
- * The local discovery catalog. It holds one record per (node, address), so two addresses for the
96
- * same node stay two observations until a direct answer shows they are the same instance.
97
- *
98
- * Nothing here trusts a third party: a record enters the catalog as a candidate clue, and only a
99
- * direct `GET /description` makes it usable for routing.
100
- */
101
- export class DiscoveryService {
102
- /** Directly verified identities in this process; lets admission policies answer without nested storage work. */
103
- readonly #known = new Set<NodeId>();
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
- }
111
-
112
- /** Whether this process has directly verified this identity at least once. */
113
- known(node_id: NodeId): boolean { return this.#known.has(node_id); }
114
-
115
- get #allowLoopback(): boolean { return Boolean(this.#ctx.options.plugins.allow_loopback); }
116
-
117
- /**
118
- * Reads `base_url`'s service description and records what it declares. A failed observation
119
- * keeps the last verified identity of the address and stores the failure beside it.
120
- */
121
- async observe(base_url: string, source: string, expectedNode?: NodeId, signal?: AbortSignal): Promise<DiscoveryRecord> {
122
- if (source.length === 0) fail('invalid_request', 'A discovery record needs a source.');
123
- const url = address(base_url);
124
- if (url.protocol === 'http:' && !loopback(url)) fail('unavailable', 'Plain HTTP is limited to loopback; cross-host candidates must use HTTPS.');
125
- const at = this.#ctx.now();
126
- let description: ServiceDescription;
127
- try {
128
- description = published(await this.#read(url, signal));
129
- if (expectedNode !== undefined && description.node_id !== expectedNode) {
130
- fail('unavailable', 'The candidate description names another node.');
131
- }
132
- } catch (error) {
133
- if (signal?.aborted) throw signal.reason;
134
- await this.#failed(base_url, source, at,
135
- error instanceof OnlyneError ? error.error : { code: 'internal', message: 'The candidate observation failed.', retryable: false });
136
- throw error;
137
- }
138
- const record: DiscoveryRecord = {
139
- node_id: description.node_id,
140
- instance_id: description.instance_id,
141
- base_url,
142
- last_seen: iso(at),
143
- source,
144
- received_at: iso(at),
145
- expires_at: iso(at + FRESH_MS),
146
- verified_at: iso(at),
147
- membership: description.membership,
148
- };
149
- await this.#ctx.storage.transaction(async tx => { await tx.put(key(PREFIX, record.node_id, base_url), record); });
150
- this.#known.add(record.node_id);
151
- return record;
152
- }
153
-
154
- /** The public candidate exchange: verified, fresh and reachable from somewhere other than this host. */
155
- async list(options: PageOptions = {}): Promise<Page<DiscoveryCandidate>> {
156
- const at = this.#ctx.now();
157
- const candidates = (await this.listRecords())
158
- .filter(record => record.verified_at !== undefined && record.instance_id !== ''
159
- && exchangeable(record.base_url, this.#allowLoopback) && Date.parse(record.expires_at) > at)
160
- .map(record => ({ node_id: record.node_id, instance_id: record.instance_id, base_url: record.base_url, last_seen: record.last_seen }))
161
- .sort((a, b) => order(position(a), position(b)));
162
- return page(candidates, options, { kind: 'discovery', node: this.#ctx.node }, position);
163
- }
164
-
165
- /** Every observation this node holds, expired and failed ones included. */
166
- async listRecords(): Promise<DiscoveryRecord[]> {
167
- const rows = await this.#ctx.storage.transaction(tx => tx.scan<DiscoveryRecord>(`${PREFIX}/`));
168
- return rows.map(row => row.value).sort((a, b) => order(position(a), position(b)));
169
- }
170
-
171
- /**
172
- * Rechecks every recorded address for one identity. Two live instances of one NodeId are a
173
- * conflict; the same instance at several addresses is one target. An address that no longer
174
- * answers is skipped, so a restarted node's new address is usable immediately.
175
- */
176
- async resolve(node_id: NodeId): Promise<Target> {
177
- const records = (await this.listRecords()).filter(record => record.node_id === node_id);
178
- if (records.length === 0) fail('not_found', 'No discovery record names this node.');
179
- const answers = await Promise.all(records.map(async record => {
180
- try { return await this.observe(record.base_url, record.source, node_id); } catch { return undefined; }
181
- }));
182
- const live = new Map<string, string[]>();
183
- for (const record of answers) {
184
- if (record === undefined) continue;
185
- const addresses = live.get(record.instance_id) ?? [];
186
- addresses.push(record.base_url);
187
- live.set(record.instance_id, addresses);
188
- }
189
- if (live.size > 1) fail('conflict', 'More than one live instance answers for this node.');
190
- const entries = [...live.entries()];
191
- if (entries.length === 0) fail('unavailable', 'No recorded address for this node answered.');
192
- const addresses = entries[0]![1].sort(order);
193
- return { node_id, base_url: addresses[0]! };
194
- }
195
-
196
- /** Verified, fresh candidates that declare exactly this region. */
197
- async candidates(domain_id: DomainId): Promise<NodeId[]> {
198
- validateDomainId(domain_id);
199
- const at = this.#ctx.now();
200
- const nodes = new Set<NodeId>();
201
- for (const record of await this.listRecords()) {
202
- if (record.verified_at === undefined || record.instance_id === '') continue;
203
- if (Date.parse(record.expires_at) <= at || !exchangeable(record.base_url, this.#allowLoopback)) continue;
204
- const membership = record.membership;
205
- if (membership === null || membership === undefined || membership.state !== 'active' || membership.domain_id !== domain_id) continue;
206
- nodes.add(record.node_id);
207
- }
208
- return [...nodes].sort(order);
209
- }
210
-
211
- /** Reobserves every recorded address. One unreachable candidate commits its own failure and no other. */
212
- async refresh(signal?: AbortSignal): Promise<void> {
213
- const records = await this.listRecords();
214
- await Promise.all(records.map(record => this.observe(record.base_url, record.source, record.node_id, signal).then(() => undefined, () => undefined)));
215
- if (signal?.aborted) throw signal.reason;
216
- }
217
-
218
- async #read(url: URL, signal?: AbortSignal): Promise<unknown> {
219
- const target = new URL('./description', url.href.endsWith('/') ? url.href : `${url.href}/`);
220
- const controller = new AbortController();
221
- const relay = () => controller.abort(signal?.reason);
222
- if (signal?.aborted) controller.abort(signal.reason);
223
- else signal?.addEventListener('abort', relay, { once: true });
224
- const gate = Promise.withResolvers<never>();
225
- controller.signal.addEventListener('abort', () => gate.reject(controller.signal.reason), { once: true });
226
- const timeout = setTimeout(
227
- () => controller.abort(new OnlyneError('unavailable', 'The candidate description lookup timed out.')),
228
- this.#ctx.options.wait_ms ?? 30_000,
229
- );
230
- let response: Response;
231
- try {
232
- response = await Promise.race([
233
- this.#fetcher(target, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
234
- gate.promise,
235
- ]);
236
- } catch (error) {
237
- if (signal?.aborted) throw signal.reason;
238
- throw error instanceof OnlyneError ? error : new OnlyneError('unavailable', 'The candidate could not be reached.');
239
- } finally {
240
- clearTimeout(timeout);
241
- signal?.removeEventListener('abort', relay);
242
- }
243
-
244
- let text: string;
245
- try { text = await response.text(); } catch { throw new OnlyneError('unavailable', 'The candidate response could not be read.'); }
246
- let parsed: unknown;
247
- try { parsed = text === '' ? undefined : JSON.parse(text); } catch { parsed = undefined; }
248
- if (!response.ok) {
249
- const record = parsed !== null && typeof parsed === 'object' && 'error' in parsed ? parsed.error : parsed;
250
- if (isFailureRecord(record)) throw new OnlyneError(record.code, record.message, record.details);
251
- throw new OnlyneError('unavailable', `The candidate answered HTTP ${response.status} without a service description.`);
252
- }
253
- if (parsed === undefined) throw new OnlyneError('unavailable', 'The candidate returned no service description.');
254
- return parsed;
255
- }
256
-
257
- /**
258
- * A failure is recorded beside addresses this node already observed; a clue that never
259
- * verified leaves no record. A record whose freshness window has already lapsed is
260
- * deleted instead: a row nobody can reach and nobody would trust as fresh is a
261
- * candidate that has to be rediscovered, not a permanent entry.
262
- */
263
- async #failed(base_url: string, source: string, at: number, error: Failure): Promise<void> {
264
- await this.#ctx.storage.transaction(async tx => {
265
- const rows = (await tx.scan<DiscoveryRecord>(`${PREFIX}/`)).filter(row => row.value.base_url === base_url);
266
- for (const row of rows) {
267
- if (row.value.verified_at !== undefined) {
268
- await tx.put(row.key, { ...row.value, source, received_at: iso(at), error });
269
- continue;
270
- }
271
- await tx.delete(row.key);
272
- }
273
- });
274
- }
275
- }
package/src/domains.ts DELETED
@@ -1,213 +0,0 @@
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
-
5
- export function validateDomainId(value: unknown): string {
6
- if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$/.test(value)) {
7
- fail('invalid_request', 'A domain_id must be an absolute URI.');
8
- }
9
- return value;
10
- }
11
-
12
- /**
13
- * Shape check for a membership as published on the wire. `null` is a valid declaration of no
14
- * membership; `undefined` means the value is not a membership at all.
15
- */
16
- export function membershipRecord(value: unknown): DomainMembership | null | undefined {
17
- if (value === null) return null;
18
- if (typeof value !== 'object' || Array.isArray(value)) return undefined;
19
- if (!('domain_id' in value) || !('node_id' in value) || !('state' in value) || !('revision' in value)) return undefined;
20
- const { domain_id, node_id, state, revision } = value;
21
- if (typeof domain_id !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$/.test(domain_id)) return undefined;
22
- if (typeof node_id !== 'string' || node_id.length === 0) return undefined;
23
- if (state !== 'active' && state !== 'left') return undefined;
24
- if (typeof revision !== 'string' || revision.length === 0) return undefined;
25
- return { domain_id, node_id, state, revision };
26
- }
27
-
28
- /**
29
- * A service description is valid evidence only when it speaks this protocol, names the node that
30
- * answered, and publishes a membership record for that same node.
31
- */
32
- function declared(value: unknown, node: string): { membership: DomainMembership | null } | undefined {
33
- if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
34
- if (!('protocol' in value) || !('node_id' in value) || !('membership' in value)) return undefined;
35
- if (value.protocol !== 'onlyne/3' || value.node_id !== node) return undefined;
36
- const membership = membershipRecord(value.membership);
37
- if (membership === undefined || (membership !== null && membership.node_id !== node)) return undefined;
38
- return { membership };
39
- }
40
-
41
- async function activeRows(tx: Transaction, node: string): Promise<DomainMembership[]> {
42
- return (await tx.scan<DomainMembership>(key('domain') + '/'))
43
- .map(row => row.value)
44
- .filter(row => row.node_id === node && row.state === 'active');
45
- }
46
-
47
- /**
48
- * The one active membership a node may hold, read inside the caller's transaction.
49
- * Storage written by another revision can hold several; that state is refused instead of
50
- * silently choosing one, so the single-membership rule stays visible to the host.
51
- */
52
- async function activeMembership(tx: Transaction, node: string): Promise<DomainMembership | undefined> {
53
- const rows = await activeRows(tx, node);
54
- if (rows.length > 1) fail('conflict', 'This node holds more than one active domain membership; resolve it before continuing.');
55
- return rows[0];
56
- }
57
-
58
- export class DomainService {
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
- }
66
-
67
- private authenticate(auth: Auth): void {
68
- if (!auth.actor) fail('unauthenticated', 'Authentication is required.');
69
- }
70
-
71
- private self(auth: Auth): void {
72
- this.authenticate(auth);
73
- if (auth.actor !== this.#ctx.node) fail('forbidden', 'A node may only change its own domain membership.');
74
- }
75
-
76
- async create(auth: Auth, mutation: Mutation): Promise<DomainMembership> {
77
- this.self(auth);
78
- return this.#ctx.mutate(auth, 'domain.create', mutation, {}, async tx => {
79
- if (await activeMembership(tx, this.#ctx.node)) {
80
- fail('conflict', 'This node already belongs to a domain; leave it before creating another.');
81
- }
82
- const domain_id = validateDomainId(`urn:onlyne:region:${this.#ctx.id()}`);
83
- const slot = key('domain', domain_id);
84
- if (await tx.get(slot)) fail('id_conflict', 'The domain identifier is already recorded here.');
85
- const record: DomainMembership = { domain_id, node_id: this.#ctx.node, state: 'active', revision: this.#ctx.id() };
86
- await tx.put(slot, record);
87
- return record;
88
- });
89
- }
90
-
91
- async join(auth: Auth, id: string, mutation: DomainMutation): Promise<DomainMembership> {
92
- return this.change(auth, id, 'active', mutation);
93
- }
94
-
95
- async leave(auth: Auth, id: string, mutation: DomainMutation): Promise<DomainMembership> {
96
- return this.change(auth, id, 'left', mutation);
97
- }
98
-
99
- private async change(auth: Auth, id: string, state: DomainMembership['state'], mutation: DomainMutation): Promise<DomainMembership> {
100
- this.self(auth);
101
- validateDomainId(id);
102
- return this.#ctx.mutate(auth, `domain.${state}/${id}`, mutation, { expected_revision: mutation.expected_revision ?? null }, async tx => {
103
- const slot = key('domain', id);
104
- const current = await tx.get<DomainMembership>(slot);
105
- if (!current && state === 'left') fail('not_found', 'No membership is recorded for this domain.');
106
- // A node belongs to one domain. Cross-domain reach is a property of threads and ingress, not of membership.
107
- const active = await activeMembership(tx, this.#ctx.node);
108
- if (state === 'active' && active !== undefined && active.domain_id !== id) {
109
- fail('conflict', 'This node already belongs to another domain; leave it first.');
110
- }
111
- if (current?.revision !== mutation.expected_revision) {
112
- fail('revision_conflict', 'The local domain membership changed.', current ? { current_revision: current.revision } : undefined);
113
- }
114
- if (current?.state === state) return current;
115
- const revision = this.#ctx.id();
116
- if (revision === current?.revision) fail('internal', 'The revision source reused the current membership revision.');
117
- const record: DomainMembership = { domain_id: id, node_id: this.#ctx.node, state, revision };
118
- await tx.put(slot, record);
119
- return record;
120
- });
121
- }
122
-
123
- async list(auth: Auth, options: PageOptions = {}) {
124
- this.authenticate(auth);
125
- return this.#ctx.storage.transaction(async tx => {
126
- const current = await activeMembership(tx, this.#ctx.node);
127
- const records = current === undefined ? [] : [current];
128
- records.sort((a, b) => a.domain_id < b.domain_id ? -1 : a.domain_id > b.domain_id ? 1 : 0);
129
- return page(records, options, { kind: 'domains', node: this.#ctx.node, actor: auth.actor }, row => row.domain_id);
130
- });
131
- }
132
-
133
- async describe(auth: Auth, id: string): Promise<DomainMembership> {
134
- this.authenticate(auth);
135
- validateDomainId(id);
136
- return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<DomainMembership>(key('domain', id))));
137
- }
138
-
139
- async discover(auth: Auth, id: string, signal?: AbortSignal): Promise<DomainDiscovery> {
140
- this.authenticate(auth);
141
- validateDomainId(id);
142
- if ((await this.describe(auth, id)).state !== 'active') fail('not_a_member', 'This node has left the domain.');
143
- const candidates = this.#ctx.options.plugins.domainCandidates;
144
- if (!candidates) fail('unsupported_delivery', 'The host provides no domain discovery source.');
145
- signal?.throwIfAborted();
146
- const nodes = [...new Set([this.#ctx.node, ...await candidates(id)])].sort();
147
- const members: DomainMembership[] = [];
148
- const unavailable: string[] = [];
149
- for (const node of nodes) {
150
- signal?.throwIfAborted();
151
- if (typeof node !== 'string' || node.length === 0) fail('invalid_request', 'Domain candidates must be node identities.');
152
- if (node === this.#ctx.node) {
153
- const current = await this.describe(auth, id);
154
- if (current.state === 'active') members.push(current);
155
- continue;
156
- }
157
- try {
158
- // Only the candidate's own description is evidence: protocol and identity must agree, and
159
- // a node that declares another region (or none) is excluded rather than called unreachable.
160
- const declaration = declared(await this.query(node, signal), node);
161
- if (declaration === undefined) unavailable.push(node);
162
- else if (declaration.membership !== null && declaration.membership.domain_id === id && declaration.membership.state === 'active') members.push(declaration.membership);
163
- } catch {
164
- signal?.throwIfAborted();
165
- unavailable.push(node);
166
- }
167
- }
168
- return { domain_id: id, members, unavailable };
169
- }
170
-
171
- private async query(node: string, signal?: AbortSignal): Promise<unknown> {
172
- // A candidate's service description is public: it is read before any key exchange, so
173
- // it never goes through the signing transport (RFC 0011 §5, RFC 0017 §4).
174
- const target = await this.#ctx.options.plugins.resolve(node);
175
- if (target.node_id !== node) fail('unavailable', 'The resolver returned a target for another identity.');
176
- const base = serviceBase(target.base_url);
177
- const url = new URL('./description', base.href.endsWith('/') ? base.href : `${base.href}/`);
178
- const controller = new AbortController();
179
- let timer: ReturnType<typeof setTimeout> | undefined;
180
- const onAbort = () => controller.abort(signal?.reason);
181
- signal?.addEventListener('abort', onAbort, { once: true });
182
- if (signal?.aborted) controller.abort(signal.reason);
183
- const aborted = new Promise<never>((_, reject) => {
184
- controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
185
- if (controller.signal.aborted) reject(controller.signal.reason);
186
- });
187
- timer = setTimeout(() => controller.abort(new OnlyneError('unavailable', 'Service description lookup timed out.')), this.#ctx.options.wait_ms ?? 30_000);
188
- try {
189
- const response = await Promise.race([
190
- this.#fetcher(url, { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal }),
191
- aborted,
192
- ]);
193
- const text = await response.text().catch(() => '');
194
- let parsed: unknown;
195
- try { parsed = text === '' ? undefined : JSON.parse(text); } catch { parsed = undefined; }
196
- if (!response.ok) {
197
- const record = parsed !== null && typeof parsed === 'object' && 'error' in parsed ? (parsed as { error: unknown }).error : parsed;
198
- if (record !== null && typeof record === 'object' && 'code' in record && 'message' in record) {
199
- const failure = record as { code: string; message: string; retryable?: boolean };
200
- const rejection = new OnlyneError(failure.code as never, failure.message);
201
- if (typeof failure.retryable === 'boolean') rejection.error.retryable = failure.retryable;
202
- throw rejection;
203
- }
204
- fail('unavailable', `The candidate answered HTTP ${response.status} without a service description.`);
205
- }
206
- if (parsed === undefined) fail('unavailable', 'The candidate returned no service description.');
207
- return parsed;
208
- } finally {
209
- clearTimeout(timer);
210
- signal?.removeEventListener('abort', onAbort);
211
- }
212
- }
213
- }