@agnt-sdk/simon-server 0.0.0-stage → 0.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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding other servers and their keys: resolution (spec section 3), server and agent documents (section 4),
|
|
3
|
+
* and key continuity (section 11.1, continuity.ts).
|
|
4
|
+
*
|
|
5
|
+
* - **Caching.** Within the spec's bounds (`resolutionCacheMs`): a `server` answer for ten minutes, the
|
|
6
|
+
* TTL assumed since c-ares does not report one; `not_found` for ten minutes; `temporary` for a minute,
|
|
7
|
+
* so a failing domain costs one lookup a minute, however often it is asked for. Documents are cached
|
|
8
|
+
* ten minutes, and a server document that failed for now one minute.
|
|
9
|
+
* - **Refetching.** A signature that fails against a cached document is checked again against a fresh
|
|
10
|
+
* one, though a server document is fetched at most once a minute (anyone can name any server).
|
|
11
|
+
* - **Bounded outbound work.** Concurrent asks for the same answer share one lookup, and every lookup is
|
|
12
|
+
* paid for by whoever caused it (`Payer`): an unauthenticated client, counted before each lookup; a
|
|
13
|
+
* carrier whose signature verified, for the work its delivery asks (its recipients' domains, its
|
|
14
|
+
* claims, its author's document); or this server itself, for an agent's send and its outbox. Each runs
|
|
15
|
+
* in its own bounded pool, and a carrier has at most MAX_LOOKUPS_PER_CARRIER of its pool (more are
|
|
16
|
+
* refused for now, 503), so nothing another server or an unauthenticated client asks for can starve
|
|
17
|
+
* the server's own sending (spec section 11.2). The server's own work for an agent here, its send's and
|
|
18
|
+
* its messages' deliveries, has at most MAX_LOOKUPS_PER_AGENT of the own pool (more are refused for now,
|
|
19
|
+
* and a delivery retried), so no agent's recipients, slow as they may be, hold up another's.
|
|
20
|
+
* - **The bound before a signature verifies** (spec section 11.2). Every lookup an unauthenticated caller
|
|
21
|
+
* causes counts against its client network (`lookup`, in this process) and against the registrable
|
|
22
|
+
* domain of the name looked up (`lookupDomain`, by the Public Suffix List's ICANN section, in the store,
|
|
23
|
+
* so it holds for every process sharing it). Only lookups made count: a cache hit, or a lookup another
|
|
24
|
+
* ask already has underway, costs nothing. Past either bound the lookup is `429`, and it is not counted.
|
|
25
|
+
*/
|
|
26
|
+
import { addressSegment, DOCUMENT_CACHE_MS, MAX_DOCUMENT_BYTES, readAgentDocument, readServerDocument, resolutionCacheMs, resolveDomain, SERVER_DOCUMENT_REFETCH_MS, TEMPORARY_CACHE_MS, } from '@agnt-sdk/simon-protocol';
|
|
27
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
28
|
+
import { Continuity } from "./continuity.js";
|
|
29
|
+
import { rateLimited, unavailable } from "./errors.js";
|
|
30
|
+
import { registrableDomain, shippedPublicSuffixes } from "./net/psl.js";
|
|
31
|
+
import { Unreachable } from "./net/fetch.js";
|
|
32
|
+
/** The TTL taken for a `server` answer, whose DNS TTL or HTTP freshness this resolver does not read. */
|
|
33
|
+
const SERVER_TTL_MS = 10 * 60_000;
|
|
34
|
+
/** Lookups at once: for unauthenticated callers, for carriers whose signature verified, and for this server's own work. */
|
|
35
|
+
const MAX_LOOKUPS = { charged: 24, carrier: 32, own: 16 };
|
|
36
|
+
/** A carrier's lookups at once, of the carrier pool. */
|
|
37
|
+
export const MAX_LOOKUPS_PER_CARRIER = 8;
|
|
38
|
+
/**
|
|
39
|
+
* The lookups at once of every carrier whose deliveries come from one network (`networkKey`): carriers cost
|
|
40
|
+
* nothing to make up, networks do. Twice a carrier's share, so carriers behind one egress network, the
|
|
41
|
+
* tenants of a host behind one NAT for instance, do not share one carrier's share, while no one network
|
|
42
|
+
* holds more than half the carrier pool.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_LOOKUPS_PER_NETWORK = 2 * MAX_LOOKUPS_PER_CARRIER;
|
|
45
|
+
/**
|
|
46
|
+
* An agent here's lookups at once, of the own pool: its send's and its messages' deliveries'. Not shared by
|
|
47
|
+
* the agents of a domain, as every agent of a hosted domain is on it.
|
|
48
|
+
*/
|
|
49
|
+
export const MAX_LOOKUPS_PER_AGENT = 4;
|
|
50
|
+
const MAX_CACHED = 10_000;
|
|
51
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
52
|
+
/** A small TTL cache that forgets its oldest entries first. */
|
|
53
|
+
class Cache {
|
|
54
|
+
#entries = new Map();
|
|
55
|
+
get(key, now) {
|
|
56
|
+
const hit = this.#entries.get(key);
|
|
57
|
+
if (!hit || hit.until <= now)
|
|
58
|
+
return null;
|
|
59
|
+
return hit;
|
|
60
|
+
}
|
|
61
|
+
set(key, value, now, ttl) {
|
|
62
|
+
this.#entries.delete(key);
|
|
63
|
+
if (ttl <= 0)
|
|
64
|
+
return;
|
|
65
|
+
this.#entries.set(key, { value, until: now + ttl, at: now });
|
|
66
|
+
if (this.#entries.size > MAX_CACHED)
|
|
67
|
+
this.#entries.delete(this.#entries.keys().next().value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The pool a payer's lookups run in. */
|
|
71
|
+
const poolOf = (payer) => payer === undefined || (typeof payer === 'object' && 'agent' in payer)
|
|
72
|
+
? 'own'
|
|
73
|
+
: typeof payer === 'function'
|
|
74
|
+
? 'charged'
|
|
75
|
+
: 'carrier';
|
|
76
|
+
const REFUSED = Symbol('refused');
|
|
77
|
+
/** Mark an error as a lookup refused before it was made: for room, or by its payer's rate or domain bound. */
|
|
78
|
+
function refused(e) {
|
|
79
|
+
if (typeof e === 'object' && e !== null)
|
|
80
|
+
Object.defineProperty(e, REFUSED, { value: true });
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
/** Whether an error is a lookup refused before it was made, so nothing was looked up. */
|
|
84
|
+
export const isRefusal = (e) => typeof e === 'object' && e !== null && REFUSED in e;
|
|
85
|
+
/** Store scope of the per-registrable-domain counts of lookups made before a signature verifies. */
|
|
86
|
+
const DOMAIN_LOOKUPS = 'lookup-domain';
|
|
87
|
+
/** A failure that may pass, as a cached document lookup remembers it. */
|
|
88
|
+
const TEMPORARY = 'temporary';
|
|
89
|
+
export class Resolver {
|
|
90
|
+
#d;
|
|
91
|
+
#domains = new Cache();
|
|
92
|
+
#servers = new Cache();
|
|
93
|
+
#agents = new Cache();
|
|
94
|
+
#pending = new Map();
|
|
95
|
+
#continuity;
|
|
96
|
+
#lookups = { charged: 0, carrier: 0, own: 0 };
|
|
97
|
+
/** The carrier pool's lookups underway, per carrier and per network. */
|
|
98
|
+
#shares = new Map();
|
|
99
|
+
/** The tally of the `tallied` call underway, which lookups made (or joined) within it mark. */
|
|
100
|
+
#tally = new AsyncLocalStorage();
|
|
101
|
+
constructor(deps) {
|
|
102
|
+
this.#d = deps;
|
|
103
|
+
this.#continuity = new Continuity({
|
|
104
|
+
store: deps.store,
|
|
105
|
+
now: deps.now,
|
|
106
|
+
log: deps.log,
|
|
107
|
+
holdSeconds: deps.config.keyChangeHoldSeconds,
|
|
108
|
+
// Confirming a held change is work for the server that changed, in its own share of the carrier pool,
|
|
109
|
+
// counted against its carrierLookup rate as its other lookups are.
|
|
110
|
+
confirm: async (domain, server, keys) => {
|
|
111
|
+
const payer = { carrier: server, charge: () => deps.chargeCarrier?.(server) };
|
|
112
|
+
const again = await this.resolve(domain, { fresh: true, charge: payer });
|
|
113
|
+
if (again.outcome !== 'server' || again.server !== server)
|
|
114
|
+
return false;
|
|
115
|
+
const doc = await this.serverDocument(server, { force: true, charge: payer });
|
|
116
|
+
return (!!doc &&
|
|
117
|
+
doc.keys
|
|
118
|
+
.map(k => k.key)
|
|
119
|
+
.sort()
|
|
120
|
+
.join() === [...keys].sort().join());
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
get #self() {
|
|
125
|
+
return this.#d.config.origin;
|
|
126
|
+
}
|
|
127
|
+
/** Resolution of a domain, from the cache unless `fresh`. */
|
|
128
|
+
async resolve(domain, opts = {}) {
|
|
129
|
+
const now = this.#d.now().getTime();
|
|
130
|
+
const hit = opts.fresh ? null : this.#domains.get(domain, now);
|
|
131
|
+
if (hit)
|
|
132
|
+
return hit.value;
|
|
133
|
+
const override = this.#d.config.devResolve[domain];
|
|
134
|
+
if (override)
|
|
135
|
+
return { outcome: 'server', server: override.replace(/\/$/, ''), via: 'dns' };
|
|
136
|
+
return this.#once(`resolve ${domain}`, opts.charge, domain, async () => {
|
|
137
|
+
const r = await resolveDomain(domain, { txt: name => this.#d.dns.txt(name), get: url => this.#wellKnown(url) }, { allowHttp: this.#d.config.dev });
|
|
138
|
+
this.#domains.set(domain, r, this.#d.now().getTime(), resolutionCacheMs(r, SERVER_TTL_MS));
|
|
139
|
+
return r;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Whether a resolution of `domain` is cached now, so a fresh one might answer otherwise. */
|
|
143
|
+
isCached(domain) {
|
|
144
|
+
return this.#domains.get(domain, this.#d.now().getTime()) !== null;
|
|
145
|
+
}
|
|
146
|
+
/** Whether a domain names this server now. Throws 503 when that cannot be known. */
|
|
147
|
+
async servesHere(domain, charge, fresh = false) {
|
|
148
|
+
const r = await this.resolve(domain, { charge, fresh });
|
|
149
|
+
if (r.outcome === 'temporary')
|
|
150
|
+
throw unavailable(`Could not resolve ${domain} just now.`);
|
|
151
|
+
return r.outcome === 'server' && r.server === this.#self;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The server a domain names and its document, trusted under key continuity's listing check. `document`
|
|
155
|
+
* is null when the server serves no readable one. Throws 503 when resolution or the document failed for
|
|
156
|
+
* now, or while a key change is held.
|
|
157
|
+
*/
|
|
158
|
+
async peer(domain, charge, opts = {}) {
|
|
159
|
+
const r = await this.resolve(domain, { charge, fresh: opts.fresh });
|
|
160
|
+
if (r.outcome === 'temporary')
|
|
161
|
+
throw unavailable(`Could not resolve ${domain} just now.`);
|
|
162
|
+
if (r.outcome !== 'server')
|
|
163
|
+
return r;
|
|
164
|
+
const document = await this.serverDocument(r.server, { charge, refresh: opts.fresh });
|
|
165
|
+
if (document && r.server !== this.#self)
|
|
166
|
+
await this.#continuity.listed(domain, r.server, keysOf(document));
|
|
167
|
+
return { outcome: 'server', server: r.server, document };
|
|
168
|
+
}
|
|
169
|
+
/** A signature by `key` from a domain's server, under `document`: held unless `key` is trusted to sign. */
|
|
170
|
+
trustSigner(domain, server, key, document) {
|
|
171
|
+
return this.#continuity.signed(domain, server, key, keysOf(document));
|
|
172
|
+
}
|
|
173
|
+
/** A trusted signer signed afresh under `document`, vouching for each key it lists. */
|
|
174
|
+
vouch(domain, document) {
|
|
175
|
+
return this.#continuity.vouch(domain, keysOf(document));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A server's document, or null when it serves none that is readable. `refresh` fetches again when the
|
|
179
|
+
* cached one is a minute old or more; `force` always does. Throws 503 on a failure that may pass.
|
|
180
|
+
*/
|
|
181
|
+
async serverDocument(origin, opts = {}) {
|
|
182
|
+
if (origin === this.#self)
|
|
183
|
+
return this.#d.ownDocument();
|
|
184
|
+
const now = this.#d.now().getTime();
|
|
185
|
+
const hit = this.#servers.get(origin, now);
|
|
186
|
+
const stale = hit && (opts.force || (opts.refresh && now - hit.at >= SERVER_DOCUMENT_REFETCH_MS));
|
|
187
|
+
const doc = hit && !stale
|
|
188
|
+
? hit.value
|
|
189
|
+
: await this.#once(`server ${origin}`, opts.charge, new URL(origin).hostname, async () => {
|
|
190
|
+
let fetched;
|
|
191
|
+
try {
|
|
192
|
+
const res = await this.#getDocument(`${origin}/simon/server`);
|
|
193
|
+
fetched = res && readServerDocument(res.body, origin, this.#d.now());
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
fetched = TEMPORARY;
|
|
197
|
+
}
|
|
198
|
+
// A failure for now does not hide a document already known, but is remembered for a minute.
|
|
199
|
+
const ttl = fetched === TEMPORARY ? TEMPORARY_CACHE_MS : DOCUMENT_CACHE_MS;
|
|
200
|
+
if (fetched !== TEMPORARY || !hit)
|
|
201
|
+
this.#servers.set(origin, fetched, this.#d.now().getTime(), ttl);
|
|
202
|
+
return fetched;
|
|
203
|
+
});
|
|
204
|
+
if (doc === TEMPORARY)
|
|
205
|
+
throw unavailable(`Could not fetch the server document of ${origin} just now.`);
|
|
206
|
+
return doc;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* An agent's kind and keys: from the store for an agent here, otherwise from its signed agent document
|
|
210
|
+
* on `server`. Null when there is no readable document. `refresh` skips the cache (a signature failed).
|
|
211
|
+
* `charge` names who pays for the fetch, as `resolve`'s does.
|
|
212
|
+
*/
|
|
213
|
+
async agentKeys(address, server, refresh = false, charge) {
|
|
214
|
+
if (server === this.#self)
|
|
215
|
+
return this.#d.localAgent(address);
|
|
216
|
+
const now = this.#d.now();
|
|
217
|
+
const key = `${server} ${address}`;
|
|
218
|
+
// A cached answer that there is no document is kept against an unauthenticated caller's refetch too, as
|
|
219
|
+
// section 4 allows, so naming an agent that does not exist costs one lookup in ten minutes, not one each.
|
|
220
|
+
const hit = this.#agents.get(key, now.getTime());
|
|
221
|
+
if (hit && (!refresh || (hit.value === null && typeof charge === 'function')))
|
|
222
|
+
return hit.value;
|
|
223
|
+
const res = await this.#once(`agent ${key}`, charge, new URL(server).hostname, () => this.#getDocument(`${server}/simon/agents/${addressSegment(address)}`));
|
|
224
|
+
let agent = null;
|
|
225
|
+
if (res) {
|
|
226
|
+
const read = async (opts = {}) => {
|
|
227
|
+
// The server document is fetched for whoever pays for the agent's, never on this server's own account.
|
|
228
|
+
const doc = await this.serverDocument(server, { ...opts, charge });
|
|
229
|
+
return doc && readAgentDocument(res.body, address, doc.keys, now);
|
|
230
|
+
};
|
|
231
|
+
// A document signed by a key the cached server document lacks: that document may be stale.
|
|
232
|
+
agent = (await read()) ?? (await read({ refresh: true }));
|
|
233
|
+
}
|
|
234
|
+
this.#agents.set(key, agent, now.getTime(), DOCUMENT_CACHE_MS);
|
|
235
|
+
return agent;
|
|
236
|
+
}
|
|
237
|
+
/** How many more lookups a payer may start now: its pool's room, and for a carrier, its shares'. */
|
|
238
|
+
room(payer) {
|
|
239
|
+
const pool = poolOf(payer);
|
|
240
|
+
const shares = this.#sharesOf(payer).map(([k, max]) => max - (this.#shares.get(k) ?? 0));
|
|
241
|
+
return Math.max(0, Math.min(MAX_LOOKUPS[pool] - this.#lookups[pool], ...shares));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The shares of its pool a payer's lookups count against, with their caps: a carrier's and its network's, or
|
|
245
|
+
* an agent's.
|
|
246
|
+
*/
|
|
247
|
+
#sharesOf(payer) {
|
|
248
|
+
if (typeof payer !== 'object')
|
|
249
|
+
return [];
|
|
250
|
+
if ('agent' in payer)
|
|
251
|
+
return [[`agent ${payer.agent}`, MAX_LOOKUPS_PER_AGENT]];
|
|
252
|
+
const network = payer.network ? [[`network ${payer.network}`, MAX_LOOKUPS_PER_NETWORK]] : [];
|
|
253
|
+
return [[`carrier ${payer.carrier}`, MAX_LOOKUPS_PER_CARRIER], ...network];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Run `fn`, setting `tally.made` when a lookup is made within it (one it starts, or one underway that it
|
|
257
|
+
* joins). A lookup refused before it was made, or answered from the cache, sets nothing.
|
|
258
|
+
*/
|
|
259
|
+
tallied(tally, fn) {
|
|
260
|
+
return this.#tally.run(tally, fn);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Network work, bounded: one lookup per key at a time (concurrent asks share it), charged to whoever pays
|
|
264
|
+
* before it starts (and only when there is room for it), and at most MAX_LOOKUPS of its pool at once,
|
|
265
|
+
* MAX_LOOKUPS_PER_CARRIER for one carrier and MAX_LOOKUPS_PER_NETWORK for one network. A lookup for an unauthenticated caller also
|
|
266
|
+
* counts against the registrable domain of `name`, the name looked up (`#chargeDomain`).
|
|
267
|
+
*/
|
|
268
|
+
#once(key, payer, name, work) {
|
|
269
|
+
const pending = this.#pending.get(key);
|
|
270
|
+
if (pending) {
|
|
271
|
+
const tally = this.#tally.getStore();
|
|
272
|
+
return pending.then(value => {
|
|
273
|
+
if (tally)
|
|
274
|
+
tally.made = true;
|
|
275
|
+
return value;
|
|
276
|
+
}, error => {
|
|
277
|
+
if (tally && !isRefusal(error))
|
|
278
|
+
tally.made = true;
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const pool = poolOf(payer);
|
|
283
|
+
const shares = this.#sharesOf(payer);
|
|
284
|
+
const full = shares.some(([k, max]) => (this.#shares.get(k) ?? 0) >= max);
|
|
285
|
+
if (this.#lookups[pool] >= MAX_LOOKUPS[pool] || full) {
|
|
286
|
+
throw refused(unavailable('This server is busy with lookups; try again shortly.', 5));
|
|
287
|
+
}
|
|
288
|
+
this.#lookups[pool]++;
|
|
289
|
+
for (const [k] of shares)
|
|
290
|
+
this.#shares.set(k, (this.#shares.get(k) ?? 0) + 1);
|
|
291
|
+
const charged = pool === 'charged' ? this.#chargeDomain(name) : Promise.resolve(undefined);
|
|
292
|
+
const tally = this.#tally.getStore();
|
|
293
|
+
const p = charged
|
|
294
|
+
.then(async (releaseDomain) => {
|
|
295
|
+
try {
|
|
296
|
+
// Both admissions must succeed before either account pays for outbound work.
|
|
297
|
+
(typeof payer === 'function' ? payer : payer && 'charge' in payer ? payer.charge : undefined)?.();
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
await releaseDomain?.();
|
|
301
|
+
throw refused(error);
|
|
302
|
+
}
|
|
303
|
+
if (tally)
|
|
304
|
+
tally.made = true;
|
|
305
|
+
return work();
|
|
306
|
+
})
|
|
307
|
+
.finally(() => {
|
|
308
|
+
this.#lookups[pool]--;
|
|
309
|
+
for (const [k] of shares) {
|
|
310
|
+
const n = this.#shares.get(k) - 1;
|
|
311
|
+
if (n)
|
|
312
|
+
this.#shares.set(k, n);
|
|
313
|
+
else
|
|
314
|
+
this.#shares.delete(k);
|
|
315
|
+
}
|
|
316
|
+
this.#pending.delete(key);
|
|
317
|
+
});
|
|
318
|
+
this.#pending.set(key, p);
|
|
319
|
+
return p;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Count one lookup made before a signature verifies against the registrable domain of `name`, in a store
|
|
323
|
+
* counter per window, so every process on the store shares the bound; `429` past it, and the lookup is not
|
|
324
|
+
* made. A reservation refused here or by the caller's quota is returned without consuming a lookup.
|
|
325
|
+
*/
|
|
326
|
+
async #chargeDomain(name) {
|
|
327
|
+
const { store, config } = this.#d;
|
|
328
|
+
const rate = config.rates.lookupDomain;
|
|
329
|
+
const now = this.#d.now().getTime();
|
|
330
|
+
const window = Math.floor(now / (rate.seconds * 1000));
|
|
331
|
+
const ends = (window + 1) * rate.seconds * 1000;
|
|
332
|
+
const id = `${registrableDomain(name, this.#d.publicSuffixes ?? shippedPublicSuffixes())} ${window}`;
|
|
333
|
+
const at = new Date(now).toISOString();
|
|
334
|
+
const count = await store.count(DOMAIN_LOOKUPS, id, new Date(ends).toISOString(), at);
|
|
335
|
+
const release = async () => {
|
|
336
|
+
await store.count(DOMAIN_LOOKUPS, id, new Date(ends).toISOString(), at, -1);
|
|
337
|
+
};
|
|
338
|
+
if (count <= rate.limit)
|
|
339
|
+
return release;
|
|
340
|
+
await release();
|
|
341
|
+
throw refused(rateLimited(Math.max(1, Math.ceil((ends - now) / 1000))));
|
|
342
|
+
}
|
|
343
|
+
/** GET a document: the answer when 200, null when there is none, 503 on a failure that may pass. */
|
|
344
|
+
async #getDocument(url) {
|
|
345
|
+
let res;
|
|
346
|
+
try {
|
|
347
|
+
res = await this.#d.http.request({
|
|
348
|
+
method: 'GET',
|
|
349
|
+
url,
|
|
350
|
+
maxBytes: MAX_DOCUMENT_BYTES,
|
|
351
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
const kind = e instanceof Unreachable ? e.kind : 'failed';
|
|
356
|
+
if (kind === 'absent' || kind === 'forbidden' || kind === 'too_large')
|
|
357
|
+
return null;
|
|
358
|
+
throw unavailable(`Could not fetch ${url} just now.`);
|
|
359
|
+
}
|
|
360
|
+
if (res.status === 429 || res.status >= 500)
|
|
361
|
+
throw unavailable(`${url} answered ${res.status}.`);
|
|
362
|
+
return res.status === 200 ? res : null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The well-known fetch as resolution needs it: null when there is no answer to be had (no such host, a
|
|
366
|
+
* refused connection, a certificate not valid for the host, an address this server may not call, or an
|
|
367
|
+
* answer too large to be one); a throw for a failure that may pass.
|
|
368
|
+
*/
|
|
369
|
+
async #wellKnown(url) {
|
|
370
|
+
try {
|
|
371
|
+
return await this.#d.http.request({
|
|
372
|
+
method: 'GET',
|
|
373
|
+
url,
|
|
374
|
+
maxBytes: MAX_DOCUMENT_BYTES,
|
|
375
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
const kind = e instanceof Unreachable ? e.kind : 'failed';
|
|
380
|
+
if (['absent', 'forbidden', 'refused', 'certificate', 'too_large'].includes(kind))
|
|
381
|
+
return null;
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const keysOf = (doc) => doc.keys.map(k => k.key);
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /simon/send` (CLIENT.md): an agent here hands over a message for delivery. Checked in the order the
|
|
3
|
+
* client API gives: the message as a delivery would check it (spec section 6.3, steps 1, 3 and 4); a
|
|
4
|
+
* version this server implements; `from` the agent's own address, on a domain that still names this
|
|
5
|
+
* server; the author's signature; the paired or solo rule; the 24-hour window; no message with an audience
|
|
6
|
+
* carried whole (spec section 7.1); every content-addressed
|
|
7
|
+
* part held for this agent, uploaded or received, so a part it received can be forwarded. Then the message,
|
|
8
|
+
* this server's claims about it signed for it (spec section 6.2), and one delivery row per recipient domain
|
|
9
|
+
* are stored as one, and the answer is given; delivery follows.
|
|
10
|
+
*
|
|
11
|
+
* A repeat answers as the first call did, and stores again whatever rows a store lost, so a client that
|
|
12
|
+
* retries after a crash never leaves its message undelivered.
|
|
13
|
+
*/
|
|
14
|
+
import { type AnyMessage, type Hints } from '@agnt-sdk/simon-protocol';
|
|
15
|
+
import { type Context } from './context.ts';
|
|
16
|
+
import { type Reply, type Request } from './http.ts';
|
|
17
|
+
export declare function send(ctx: Context, req: Request): Promise<Reply>;
|
|
18
|
+
/** `send` for an extension's own agent, with the message as an object: checked from step 4 on. */
|
|
19
|
+
export declare function sendAs(ctx: Context, m: AnyMessage, hints?: Hints): Promise<{
|
|
20
|
+
id: string;
|
|
21
|
+
receivedAt: string;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* The files a version 1 message names by content address: its own, and those of every message it carries
|
|
25
|
+
* whole, which count as its own (spec section 5.3). A later version's are not known here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function contentParts(m: AnyMessage): {
|
|
28
|
+
sha256: string;
|
|
29
|
+
size: number;
|
|
30
|
+
}[];
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /simon/send` (CLIENT.md): an agent here hands over a message for delivery. Checked in the order the
|
|
3
|
+
* client API gives: the message as a delivery would check it (spec section 6.3, steps 1, 3 and 4); a
|
|
4
|
+
* version this server implements; `from` the agent's own address, on a domain that still names this
|
|
5
|
+
* server; the author's signature; the paired or solo rule; the 24-hour window; no message with an audience
|
|
6
|
+
* carried whole (spec section 7.1); every content-addressed
|
|
7
|
+
* part held for this agent, uploaded or received, so a part it received can be forwarded. Then the message,
|
|
8
|
+
* this server's claims about it signed for it (spec section 6.2), and one delivery row per recipient domain
|
|
9
|
+
* are stored as one, and the answer is given; delivery follows.
|
|
10
|
+
*
|
|
11
|
+
* A repeat answers as the first call did, and stores again whatever rows a store lost, so a client that
|
|
12
|
+
* retries after a crash never leaves its message undelivered.
|
|
13
|
+
*/
|
|
14
|
+
import { carriedMessages, checkEnvelopeShape, claimsBytes, checkMessage, checkPrincipal, checkSupportedVersion, deliveryWindow, domainOf, invalid, isFresh, isJsonObject, judgeClaims, messageId, namedFiles, parseMessage, parseStrict, SEND_WINDOW_MS, sign, unauthorized, verifyMessage, } from '@agnt-sdk/simon-protocol';
|
|
15
|
+
import { authenticateAgent } from "./auth.js";
|
|
16
|
+
import { iso, later } from "./context.js";
|
|
17
|
+
import { badRequest, conflict } from "./errors.js";
|
|
18
|
+
import { json } from "./http.js";
|
|
19
|
+
import { MOVE_WINDOW_MS, SERVING_SINCE } from "./outbox.js";
|
|
20
|
+
import { changed } from "./sync.js";
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
export async function send(ctx, req) {
|
|
23
|
+
const agent = await authenticateAgent(ctx, req);
|
|
24
|
+
const m = parseMessage(req.body, ctx.config.limits);
|
|
25
|
+
return json(await write(ctx, agent, m, readHintsHeader(req.headers['simon-hints'])));
|
|
26
|
+
}
|
|
27
|
+
/** `send` for an extension's own agent, with the message as an object: checked from step 4 on. */
|
|
28
|
+
export async function sendAs(ctx, m, hints) {
|
|
29
|
+
const agent = await ctx.store.agentByAddress(m.from);
|
|
30
|
+
if (!agent || agent.retiredAt)
|
|
31
|
+
throw invalid(`${m.from} is not an agent here.`, { field: 'from' });
|
|
32
|
+
return write(ctx, agent, checkMessage(m, ctx.config.limits), hints ?? {});
|
|
33
|
+
}
|
|
34
|
+
/** The checks after the message's own, then storage. Answers the id and when it was received. */
|
|
35
|
+
async function write(ctx, agent, m, hints) {
|
|
36
|
+
checkSupportedVersion(m);
|
|
37
|
+
const id = messageId(m);
|
|
38
|
+
const now = ctx.now();
|
|
39
|
+
const earlier = await ctx.store.message(id);
|
|
40
|
+
if (earlier && earlier.message.from === agent.address && earlier.carrier === ctx.config.origin) {
|
|
41
|
+
// The claims that travel, as the first call checked them: without this server's judgement.
|
|
42
|
+
checkHints(ctx, hints, m, earlier.signedClaims?.claims ?? []);
|
|
43
|
+
await ctx.store.putMessage(earlier, { deliveries: deliveriesFor(earlier, hints) });
|
|
44
|
+
ctx.wake();
|
|
45
|
+
return { id, receivedAt: earlier.receivedAt };
|
|
46
|
+
}
|
|
47
|
+
if (m.from !== agent.address)
|
|
48
|
+
throw invalid(`This agent sends only as ${agent.address}.`, { field: 'from' });
|
|
49
|
+
const domain = domainOf(agent.address);
|
|
50
|
+
// Its lookups are the agent's, in its share of the server's own pool (resolver.ts).
|
|
51
|
+
const payer = { agent: agent.address };
|
|
52
|
+
if (!(await ctx.resolver.servesHere(domain, payer))) {
|
|
53
|
+
await ctx.store.setValue(SERVING_SINCE, domain, null, iso(now));
|
|
54
|
+
throw conflict('address_not_served', `${domain} no longer names this server.`);
|
|
55
|
+
}
|
|
56
|
+
await notedServing(ctx, domain, now);
|
|
57
|
+
if (!verifyMessage(m, agent.keys)) {
|
|
58
|
+
throw unauthorized('bad_signature', "The message does not verify with the author's keys valid at its sentAt.", now);
|
|
59
|
+
}
|
|
60
|
+
checkPrincipal(m, agent.principal === null ? { kind: 'solo' } : { kind: 'paired', for: agent.principal });
|
|
61
|
+
if (!isFresh(m, now, SEND_WINDOW_MS)) {
|
|
62
|
+
throw invalid('sentAt is more than 24 hours old or more than 5 minutes ahead.', { field: 'sentAt' });
|
|
63
|
+
}
|
|
64
|
+
checkCarriedAudience(m);
|
|
65
|
+
for (const p of contentParts(m)) {
|
|
66
|
+
if (!(await holds(ctx, agent.address, p))) {
|
|
67
|
+
throw badRequest('part_not_uploaded', `Upload part ${p.sha256} (${p.size} bytes) first.`, { sha256: p.sha256 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const made = await ctx.hooks.claimsFor(m, agent);
|
|
71
|
+
const claims = await judgeClaims(m, made, agent.kind, ctx.config.origin, d => ctx.resolver.resolve(d, { charge: payer }));
|
|
72
|
+
// What travels, and is signed for the message, is the claims considered, without this server's judgement.
|
|
73
|
+
const sent = claims.map(({ authoritative: _, ...c }) => c);
|
|
74
|
+
checkHints(ctx, hints, m, sent);
|
|
75
|
+
ctx.rate.hit('send', agent.address);
|
|
76
|
+
ctx.rate.distinct('destinations', agent.address, m.to.map(domainOf));
|
|
77
|
+
const record = {
|
|
78
|
+
id,
|
|
79
|
+
message: m,
|
|
80
|
+
carrier: ctx.config.origin,
|
|
81
|
+
claims,
|
|
82
|
+
signedClaims: sent.length ? { claims: sent, signature: sign(ctx.config.serverKey, claimsBytes(sent, id)) } : null,
|
|
83
|
+
receivedAt: iso(now),
|
|
84
|
+
expiresAt: later(now, ctx.config.retentionDays * DAY_MS),
|
|
85
|
+
};
|
|
86
|
+
const stored = await ctx.store.putMessage(record, { deliveries: deliveriesFor(record, hints) });
|
|
87
|
+
ctx.defer(() => ctx.hooks.emit('stored', { message: stored }));
|
|
88
|
+
await changed(ctx, agent.address);
|
|
89
|
+
return { id, receivedAt: stored.receivedAt };
|
|
90
|
+
}
|
|
91
|
+
/** How often a send renews what it noted about serving its domain, at most. */
|
|
92
|
+
const SERVING_CHECKED_EVERY_MS = 10 * 60_000;
|
|
93
|
+
/**
|
|
94
|
+
* Note when this server began serving an agent's domain, as far as it can tell: the outbox retries a 403 for
|
|
95
|
+
* an hour after it, while receivers' caches may still name the server before (spec section 6.3). The note
|
|
96
|
+
* lapses an hour after the last send that found the domain naming this server, so a domain that moved away
|
|
97
|
+
* and back unseen in between begins again, rather than keeping a beginning from before it left.
|
|
98
|
+
*/
|
|
99
|
+
async function notedServing(ctx, domain, now) {
|
|
100
|
+
const noted = await ctx.store.value(SERVING_SINCE, domain, iso(now));
|
|
101
|
+
const expires = later(now, MOVE_WINDOW_MS);
|
|
102
|
+
const fresh = { since: iso(now), checked: iso(now) };
|
|
103
|
+
if (!noted) {
|
|
104
|
+
await ctx.store.valueOnce(SERVING_SINCE, domain, fresh, expires, iso(now));
|
|
105
|
+
}
|
|
106
|
+
else if (typeof noted !== 'object') {
|
|
107
|
+
// An earlier version noted a bare time that never lapsed; it is read as lapsed, and the note begins anew.
|
|
108
|
+
await ctx.store.setValue(SERVING_SINCE, domain, fresh, expires);
|
|
109
|
+
}
|
|
110
|
+
else if (now.getTime() - Date.parse(noted.checked) >= SERVING_CHECKED_EVERY_MS) {
|
|
111
|
+
await ctx.store.setValue(SERVING_SINCE, domain, { since: noted.since, checked: iso(now) }, expires);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A message with an `audience` header is never forwarded or quoted whole (spec section 7.1): a message that
|
|
116
|
+
* carries one inline, at any depth, is refused as the message checks refuse a part (`400 invalid`, `parts`).
|
|
117
|
+
* One carried by content address is not seen here; its bytes are the client's to keep to itself.
|
|
118
|
+
*/
|
|
119
|
+
function checkCarriedAudience(m) {
|
|
120
|
+
for (const inner of carriedMessages(m)) {
|
|
121
|
+
const headers = inner.headers;
|
|
122
|
+
if (inner.v === 1 && isJsonObject(headers) && Object.hasOwn(headers, 'audience')) {
|
|
123
|
+
throw invalid('A message with an audience is never forwarded or quoted whole (spec section 7.1).', {
|
|
124
|
+
field: 'parts',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Verify possession at the declared size and retain received bytes as this sender's own source. Its new
|
|
131
|
+
* naming message then pins that copy even after earlier messages and their inbox grants expire.
|
|
132
|
+
*/
|
|
133
|
+
async function holds(ctx, address, p) {
|
|
134
|
+
for (const source of await ctx.store.heldFor(p.sha256, address, ctx.config.origin)) {
|
|
135
|
+
if (source.size !== p.size)
|
|
136
|
+
continue;
|
|
137
|
+
const bytes = await ctx.store.part(p.sha256, source);
|
|
138
|
+
if (bytes?.length !== p.size)
|
|
139
|
+
continue;
|
|
140
|
+
if (!('uploader' in source) || source.uploader !== address) {
|
|
141
|
+
await ctx.store.putPart(p.sha256, bytes, { uploader: address }, iso(ctx.now()));
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The files a version 1 message names by content address: its own, and those of every message it carries
|
|
149
|
+
* whole, which count as its own (spec section 5.3). A later version's are not known here.
|
|
150
|
+
*/
|
|
151
|
+
export function contentParts(m) {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
return namedFiles(m).filter(p => !seen.has(p.sha256) && seen.add(p.sha256));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The `simon-hints` header: one JSON object, in ASCII (CLIENT.md send), or `{}` when absent. A byte outside
|
|
157
|
+
* printable ASCII is refused rather than read as Latin-1 and passed on changed.
|
|
158
|
+
*/
|
|
159
|
+
function readHintsHeader(header) {
|
|
160
|
+
if (header === undefined)
|
|
161
|
+
return {};
|
|
162
|
+
if (typeof header === 'string' && /[^\x20-\x7e]/.test(header)) {
|
|
163
|
+
throw invalid('simon-hints is ASCII: escape every other character as \\uXXXX.', { field: 'simon-hints' });
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const hints = typeof header === 'string' ? parseStrict(header) : null;
|
|
167
|
+
if (isJsonObject(hints))
|
|
168
|
+
return hints;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Refused below, as any other value that is not a JSON object.
|
|
172
|
+
}
|
|
173
|
+
throw invalid('simon-hints is one JSON object keyed by recipient address.', { field: 'simon-hints' });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Hints: an object keyed by recipients in `to`, of objects of strings, small enough to travel in an
|
|
177
|
+
* envelope beside this server's claims, checked as the envelope they will travel in. The message's own
|
|
178
|
+
* signature stands in for the envelope's: only the shape is checked here.
|
|
179
|
+
*/
|
|
180
|
+
function checkHints(ctx, hints, m, claims) {
|
|
181
|
+
if (isJsonObject(hints) && Object.keys(hints).length === 0)
|
|
182
|
+
return;
|
|
183
|
+
if (m.to.length === 0)
|
|
184
|
+
throw invalid('A public message has no recipients to give hints to.', { field: 'simon-hints' });
|
|
185
|
+
const envelope = {
|
|
186
|
+
message: m,
|
|
187
|
+
server: ctx.config.origin,
|
|
188
|
+
recipients: m.to,
|
|
189
|
+
...(claims.length ? { claims, claimsSignature: m.signature } : {}),
|
|
190
|
+
hints,
|
|
191
|
+
signature: m.signature,
|
|
192
|
+
};
|
|
193
|
+
checkEnvelopeShape(envelope, ctx.config.dev);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* One delivery row per recipient domain, each with its recipients' hints, first due at `sentAt` (never
|
|
197
|
+
* before it) and ending at the delivery deadline (spec section 6.3).
|
|
198
|
+
*/
|
|
199
|
+
function deliveriesFor(record, hints) {
|
|
200
|
+
const accepted = new Date(record.receivedAt);
|
|
201
|
+
const { earliest, deadline } = deliveryWindow(record.message, accepted);
|
|
202
|
+
const byDomain = Map.groupBy(record.message.to, domainOf);
|
|
203
|
+
return [...byDomain].map(([domain, recipients]) => ({
|
|
204
|
+
id: record.id,
|
|
205
|
+
domain,
|
|
206
|
+
recipients,
|
|
207
|
+
hints: Object.fromEntries(recipients.filter(r => hints[r]).map(r => [r, hints[r]])),
|
|
208
|
+
server: null,
|
|
209
|
+
status: 'pending',
|
|
210
|
+
reason: null,
|
|
211
|
+
attempts: 0,
|
|
212
|
+
nextAttemptAt: iso(earliest > accepted ? earliest : accepted),
|
|
213
|
+
createdAt: record.receivedAt,
|
|
214
|
+
deadline: iso(deadline),
|
|
215
|
+
}));
|
|
216
|
+
}
|