@agnt-sdk/simon-server 0.0.0-stage → 0.1.1
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/parts.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed parts (spec section 7.2, CLIENT.md parts). Bytes are kept per source: the agent here
|
|
3
|
+
* that uploaded them, or the carrier they were fetched from. Bytes **held for an agent** are its uploads
|
|
4
|
+
* and the bytes fetched for messages in its inbox, so it can forward a part it received. Nothing is ever
|
|
5
|
+
* served because a message merely names a hash.
|
|
6
|
+
*
|
|
7
|
+
* PUT /simon/parts/<sha256> an agent uploads bytes before sending the message that names them; the
|
|
8
|
+
* extensions' `upload` checks may refuse them
|
|
9
|
+
* GET /simon/parts/<sha256> to readers of a published message: bytes held for its author, when its
|
|
10
|
+
* audience admits them; to a server that signs the request: bytes held for an
|
|
11
|
+
* agent here whose message, delivered to that server, names them; to an agent
|
|
12
|
+
* here: bytes held for it (`503` while they are being fetched), and what it
|
|
13
|
+
* may read as a reader of published messages
|
|
14
|
+
*
|
|
15
|
+
* The route is dispatched by who signs (spec section 7.2): an agent hosted here gets the client API's answers,
|
|
16
|
+
* its `401` for a signature that fails and `503` while a fetch is underway included; a server, by
|
|
17
|
+
* `simon-server`, the files of messages delivered to it; anyone else, a request unsigned or whose signature
|
|
18
|
+
* fails among them, the files of published messages it may read. Every refusal outside the first kind is
|
|
19
|
+
* the same `404 not_found`.
|
|
20
|
+
*
|
|
21
|
+
* **Fetching tells a carrier nothing.** The parts of every message accepted at deliver are queued at once,
|
|
22
|
+
* whoever its recipients are and whatever policy will decide, each after a random delay of up to
|
|
23
|
+
* PREFETCH_JITTER_MS; bytes of a message nobody here took are checked, then swept with it. A read never
|
|
24
|
+
* queues a fetch, and a fetch given up is final: the agent is answered `404`, never a fetch the carrier
|
|
25
|
+
* could time. Fetches run at most MAX_FETCHES at once and MAX_FETCHES_PER_CARRIER for one carrier, are
|
|
26
|
+
* taken in turn across carriers, keep MAX_FETCH_BYTES of declared size in flight, and are checked against
|
|
27
|
+
* the hash and size. Every attempt charges its declared size to its carrier's hourly budget
|
|
28
|
+
* (`fetchBytesPerHour`), whether it succeeds or not, so the budget never depends on admission; a fetch past
|
|
29
|
+
* the budget waits for the next hour without spending an attempt. The budget and the per-carrier cap are kept
|
|
30
|
+
* in the store, shared by every process on it.
|
|
31
|
+
*/
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import { domainOf, invalid, isAddress, sha256Hex, signRequest, unauthorized, WireError, } from '@agnt-sdk/simon-protocol';
|
|
34
|
+
import { authenticateAgent, authenticateServer, isAgentSigned, isServerSigned } from "./auth.js";
|
|
35
|
+
import { iso, later } from "./context.js";
|
|
36
|
+
import { notFound, unavailable } from "./errors.js";
|
|
37
|
+
import { clientKey } from "./net/ip.js";
|
|
38
|
+
import { askerOf, asReader, audienceOf, mayRead } from "./readers.js";
|
|
39
|
+
import { contentParts } from "./send.js";
|
|
40
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
41
|
+
export const PREFETCH_JITTER_MS = 3 * 60_000;
|
|
42
|
+
export const MAX_FETCHES = 4;
|
|
43
|
+
export const MAX_FETCHES_PER_CARRIER = 2;
|
|
44
|
+
export const MAX_FETCH_BYTES = 64 * 1024 * 1024;
|
|
45
|
+
const MAX_FETCH_ATTEMPTS = 6;
|
|
46
|
+
const FETCH_BACKOFF_MS = 60_000;
|
|
47
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
48
|
+
const HOUR_MS = 3600_000;
|
|
49
|
+
/** Due fetches read per tick, enough to reach a turn of every carrier with one waiting. */
|
|
50
|
+
const FETCH_WINDOW = 64;
|
|
51
|
+
/**
|
|
52
|
+
* Fetches waiting for one carrier, at most. A part past it is given up at once, as a fetch that failed is,
|
|
53
|
+
* so no carrier's messages grow the queue without bound; its rows go with the messages that name them.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_PENDING_FETCHES_PER_CARRIER = 4096;
|
|
56
|
+
/** Carriers whose last fetch start this process remembers, to take carriers in turn; the oldest are forgotten first. */
|
|
57
|
+
const MAX_REMEMBERED_CARRIERS = 10_000;
|
|
58
|
+
/** A fetch given up is remembered as long as a message naming it can be kept. */
|
|
59
|
+
const givenUp = (sha256, carrier) => `${sha256} ${carrier}`;
|
|
60
|
+
const GIVEN_UP = 'part-given-up';
|
|
61
|
+
/** A fetch of `size` bytes from `carrier`, due after a random delay of up to PREFETCH_JITTER_MS. */
|
|
62
|
+
const jittered = (ctx, sha256, carrier, size) => ({
|
|
63
|
+
sha256,
|
|
64
|
+
carrier,
|
|
65
|
+
size,
|
|
66
|
+
attempts: 0,
|
|
67
|
+
dueAt: later(ctx.now(), crypto.randomInt(PREFETCH_JITTER_MS + 1)),
|
|
68
|
+
});
|
|
69
|
+
/** Queue the fetch of an accepted message's content-addressed parts from its carrier. */
|
|
70
|
+
export async function queuePrefetch(ctx, record) {
|
|
71
|
+
if (record.carrier === ctx.config.origin)
|
|
72
|
+
return;
|
|
73
|
+
const now = iso(ctx.now());
|
|
74
|
+
for (const p of contentParts(record.message)) {
|
|
75
|
+
const f = jittered(ctx, p.sha256, record.carrier, p.size);
|
|
76
|
+
if (await ctx.store.queueFetch(f, now, MAX_PENDING_FETCHES_PER_CARRIER))
|
|
77
|
+
continue;
|
|
78
|
+
// The carrier has its whole share waiting: this part is given up, final as any fetch given up is.
|
|
79
|
+
const keep = later(ctx.now(), ctx.config.retentionDays * 86_400_000);
|
|
80
|
+
await ctx.store.mark(GIVEN_UP, givenUp(p.sha256, record.carrier), keep, now);
|
|
81
|
+
}
|
|
82
|
+
ctx.wake();
|
|
83
|
+
}
|
|
84
|
+
/** Scopes of the store state every process running fetches on one store shares. */
|
|
85
|
+
const SPENT = 'part-fetch-bytes';
|
|
86
|
+
const SLOT = 'part-fetch-slot';
|
|
87
|
+
/**
|
|
88
|
+
* A claimed fetch, and a carrier's slot, are held this long, so a process that died frees them; a fetch still
|
|
89
|
+
* running renews both before half of it is left, so no other process runs it while it lasts.
|
|
90
|
+
*/
|
|
91
|
+
const LEASE_MS = 2 * FETCH_TIMEOUT_MS;
|
|
92
|
+
/** How often, in real time, a running fetch looks whether its claim is due for renewal by the server's clock. */
|
|
93
|
+
const RENEW_CHECK_MS = 250;
|
|
94
|
+
/**
|
|
95
|
+
* Runs due part fetches within the caps, in turn across carriers. A carrier's hourly budget and its share of
|
|
96
|
+
* the fetches underway are kept in the store, so every process running fetches on it shares them, and a
|
|
97
|
+
* restart starts over on neither; a fetch is claimed in the store before it runs, so no two processes run it.
|
|
98
|
+
* MAX_FETCHES and MAX_FETCH_BYTES bound each process's own work.
|
|
99
|
+
*/
|
|
100
|
+
export class Fetcher {
|
|
101
|
+
#ctx;
|
|
102
|
+
#running = new Set();
|
|
103
|
+
/** How many fetches each carrier has underway in this process. */
|
|
104
|
+
#underway = new Map();
|
|
105
|
+
/** When each carrier's last fetch started here, as a count of starts, most recent last. */
|
|
106
|
+
#lastStart = new Map();
|
|
107
|
+
#starts = 0;
|
|
108
|
+
#bytes = 0;
|
|
109
|
+
constructor(ctx) {
|
|
110
|
+
this.#ctx = ctx;
|
|
111
|
+
}
|
|
112
|
+
/** Start due fetches, as far as the caps allow. Returns how many it started. */
|
|
113
|
+
async tick() {
|
|
114
|
+
const { store } = this.#ctx;
|
|
115
|
+
if (this.#running.size >= MAX_FETCHES)
|
|
116
|
+
return 0;
|
|
117
|
+
let started = 0;
|
|
118
|
+
/** Carriers found holding every slot this tick. */
|
|
119
|
+
const full = new Set();
|
|
120
|
+
// A slot that frees goes first to the carrier with the fewest fetches underway here; among those, to the
|
|
121
|
+
// one whose last fetch here started longest ago (one never started first), then in the store's turn. So
|
|
122
|
+
// among the carriers read, neither older backlogs nor carriers that just finished keep another waiting.
|
|
123
|
+
const due = await store.fetches(iso(this.#ctx.now()), FETCH_WINDOW);
|
|
124
|
+
const underway = (carrier) => this.#underway.get(carrier) ?? 0;
|
|
125
|
+
const last = (carrier) => this.#lastStart.get(carrier) ?? -1;
|
|
126
|
+
for (const f of due.sort((a, b) => underway(a.carrier) - underway(b.carrier) || last(a.carrier) - last(b.carrier))) {
|
|
127
|
+
if (full.has(f.carrier))
|
|
128
|
+
continue;
|
|
129
|
+
if (this.#running.size >= MAX_FETCHES)
|
|
130
|
+
break;
|
|
131
|
+
// Claimed in the store, so no other process runs it, and a row read before another settled it is skipped.
|
|
132
|
+
const claimEnds = later(this.#ctx.now(), LEASE_MS);
|
|
133
|
+
const token = crypto.randomUUID();
|
|
134
|
+
if (!(await store.claimFetch(f, iso(this.#ctx.now()), { token, until: claimEnds })))
|
|
135
|
+
continue;
|
|
136
|
+
const slot = await this.#slot(f.carrier);
|
|
137
|
+
if (!slot) {
|
|
138
|
+
full.add(f.carrier);
|
|
139
|
+
await store.fetchDefer(f.sha256, f.carrier, f.dueAt, token);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Past the bytes in flight, nothing more starts until they drain, so a large part is not passed over for
|
|
143
|
+
// ever; one larger than the whole budget still runs, alone. Judged once a slot is held, so a carrier
|
|
144
|
+
// whose slots are all held elsewhere holds up nothing.
|
|
145
|
+
if (this.#bytes + f.size > MAX_FETCH_BYTES && this.#running.size) {
|
|
146
|
+
await this.#release(slot);
|
|
147
|
+
await store.fetchDefer(f.sha256, f.carrier, f.dueAt, token);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (!(await this.#charge(f, token))) {
|
|
151
|
+
await this.#release(slot);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
this.#bytes += f.size;
|
|
155
|
+
this.#underway.set(f.carrier, underway(f.carrier) + 1);
|
|
156
|
+
this.#started(f.carrier);
|
|
157
|
+
const stopRenewing = this.#keepClaimed(f, token, claimEnds, slot);
|
|
158
|
+
const run = this.#fetch(f)
|
|
159
|
+
.then(async () => {
|
|
160
|
+
if (await stopRenewing())
|
|
161
|
+
await store.fetchDone(f.sha256, f.carrier, token);
|
|
162
|
+
})
|
|
163
|
+
.catch(async (e) => {
|
|
164
|
+
if (await stopRenewing())
|
|
165
|
+
await this.#retry(f, e, token);
|
|
166
|
+
})
|
|
167
|
+
.catch(e => this.#ctx.log('error', 'part fetch could not be settled', { error: String(e) }))
|
|
168
|
+
.finally(async () => {
|
|
169
|
+
await stopRenewing();
|
|
170
|
+
this.#bytes -= f.size;
|
|
171
|
+
if (underway(f.carrier) > 1)
|
|
172
|
+
this.#underway.set(f.carrier, underway(f.carrier) - 1);
|
|
173
|
+
else
|
|
174
|
+
this.#underway.delete(f.carrier);
|
|
175
|
+
await this.#release(slot).catch(() => { });
|
|
176
|
+
this.#running.delete(run);
|
|
177
|
+
});
|
|
178
|
+
this.#running.add(run);
|
|
179
|
+
started++;
|
|
180
|
+
}
|
|
181
|
+
return started;
|
|
182
|
+
}
|
|
183
|
+
/** Note a carrier's fetch started now, forgetting the carriers that started longest ago past the bound. */
|
|
184
|
+
#started(carrier) {
|
|
185
|
+
this.#lastStart.delete(carrier);
|
|
186
|
+
this.#lastStart.set(carrier, this.#starts++);
|
|
187
|
+
if (this.#lastStart.size > MAX_REMEMBERED_CARRIERS)
|
|
188
|
+
this.#lastStart.delete(this.#lastStart.keys().next().value);
|
|
189
|
+
}
|
|
190
|
+
/** Wait until every fetch underway has ended. */
|
|
191
|
+
async idle() {
|
|
192
|
+
while (this.#running.size)
|
|
193
|
+
await Promise.all(this.#running);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Keep a running fetch's claim, and its carrier's slot, while it runs: before half the lease is left, by
|
|
197
|
+
* the server's clock, both move LEASE_MS on, the claim by claiming the row again as of its claim's end
|
|
198
|
+
* (which only the claim that holds it can), the slot only while it is still this fetch's. A fetch that
|
|
199
|
+
* outlasts its first lease (a store slow to take the bytes, say) is then never taken up by another process.
|
|
200
|
+
* Returns what stops it.
|
|
201
|
+
*/
|
|
202
|
+
#keepClaimed(f, token, claimEnds, slot) {
|
|
203
|
+
const { store } = this.#ctx;
|
|
204
|
+
let ends = claimEnds;
|
|
205
|
+
let lost = false;
|
|
206
|
+
let renewal = null;
|
|
207
|
+
const timer = setInterval(() => {
|
|
208
|
+
const now = this.#ctx.now();
|
|
209
|
+
if (lost || renewal || now.getTime() < Date.parse(ends) - LEASE_MS / 2)
|
|
210
|
+
return;
|
|
211
|
+
const next = later(now, LEASE_MS);
|
|
212
|
+
renewal = (async () => {
|
|
213
|
+
const held = await store.renewFetch(f.sha256, f.carrier, token, iso(now), next);
|
|
214
|
+
const slotHeld = held && (await store.renewValue(slot.scope, slot.id, slot.token, next));
|
|
215
|
+
if (!held || !slotHeld) {
|
|
216
|
+
lost = true;
|
|
217
|
+
this.#ctx.log('warn', 'part fetch ownership lost', { sha256: f.sha256, carrier: f.carrier });
|
|
218
|
+
}
|
|
219
|
+
else
|
|
220
|
+
ends = next;
|
|
221
|
+
})()
|
|
222
|
+
.catch(error => {
|
|
223
|
+
lost = true;
|
|
224
|
+
this.#ctx.log('warn', 'part fetch renewal failed', { sha256: f.sha256, error: String(error) });
|
|
225
|
+
})
|
|
226
|
+
.finally(() => {
|
|
227
|
+
renewal = null;
|
|
228
|
+
});
|
|
229
|
+
}, RENEW_CHECK_MS);
|
|
230
|
+
timer.unref();
|
|
231
|
+
return async () => {
|
|
232
|
+
clearInterval(timer);
|
|
233
|
+
await renewal;
|
|
234
|
+
return !lost;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** One of the carrier's MAX_FETCHES_PER_CARRIER slots, held in the store until released: null when all are held. */
|
|
238
|
+
async #slot(carrier) {
|
|
239
|
+
for (let i = 0; i < MAX_FETCHES_PER_CARRIER; i++) {
|
|
240
|
+
const slot = await this.#lease(SLOT, `${carrier} ${i}`);
|
|
241
|
+
if (slot)
|
|
242
|
+
return slot;
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
/** A store value only this caller holds until it releases it or LEASE_MS pass, or null when another holds it. */
|
|
247
|
+
async #lease(scope, id) {
|
|
248
|
+
const { store, now } = this.#ctx;
|
|
249
|
+
const token = crypto.randomUUID();
|
|
250
|
+
const held = await store.valueOnce(scope, id, token, later(now(), LEASE_MS), iso(now()));
|
|
251
|
+
return held === token ? { scope, id, token } : null;
|
|
252
|
+
}
|
|
253
|
+
async #release({ scope, id, token }) {
|
|
254
|
+
await this.#ctx.store.releaseValue(scope, id, token);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Charge an attempt's declared size to its carrier's hour, unless that would pass the budget. A fetch past
|
|
258
|
+
* it moves to the next hour without spending an attempt, so it never holds a place among the due fetches
|
|
259
|
+
* read each tick that a carrier within its budget could have.
|
|
260
|
+
*/
|
|
261
|
+
async #charge(f, token) {
|
|
262
|
+
const { store, now, config } = this.#ctx;
|
|
263
|
+
const hour = Math.floor(now().getTime() / HOUR_MS);
|
|
264
|
+
const next = new Date((hour + 1) * HOUR_MS).toISOString();
|
|
265
|
+
const counter = `${f.carrier} ${hour}`;
|
|
266
|
+
const spent = await store.count(SPENT, counter, next, iso(now()), f.size);
|
|
267
|
+
// A part larger than the whole budget is fetched alone, at the start of an hour.
|
|
268
|
+
if (spent - f.size === 0 || spent <= config.fetchBytesPerHour)
|
|
269
|
+
return true;
|
|
270
|
+
await store.count(SPENT, counter, next, iso(now()), -f.size);
|
|
271
|
+
await store.fetchDefer(f.sha256, f.carrier, next, token);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
async #fetch(f) {
|
|
275
|
+
const ctx = this.#ctx;
|
|
276
|
+
const url = `${f.carrier}/simon/parts/${f.sha256}`;
|
|
277
|
+
const headers = signRequest(ctx.config.serverKey, {
|
|
278
|
+
method: 'GET',
|
|
279
|
+
url,
|
|
280
|
+
now: ctx.now().getTime(),
|
|
281
|
+
server: ctx.config.origin,
|
|
282
|
+
});
|
|
283
|
+
const res = await ctx.http.request({ method: 'GET', url, headers, maxBytes: f.size, timeoutMs: FETCH_TIMEOUT_MS });
|
|
284
|
+
if (res.status !== 200 || res.body.length !== f.size || sha256Hex(res.body) !== f.sha256) {
|
|
285
|
+
throw new Error(`${url} answered ${res.status} with ${res.body.length} bytes that do not match`);
|
|
286
|
+
}
|
|
287
|
+
await ctx.store.putPart(f.sha256, res.body, { carrier: f.carrier }, iso(ctx.now()));
|
|
288
|
+
}
|
|
289
|
+
async #retry(f, error, token) {
|
|
290
|
+
const { store, log, now, config } = this.#ctx;
|
|
291
|
+
log('info', 'part fetch failed', { sha256: f.sha256, carrier: f.carrier, error: String(error) });
|
|
292
|
+
if (f.attempts + 1 < MAX_FETCH_ATTEMPTS) {
|
|
293
|
+
await store.fetchRetry(f.sha256, f.carrier, later(now(), FETCH_BACKOFF_MS * 2 ** f.attempts), token);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const keep = later(now(), config.retentionDays * 86_400_000);
|
|
297
|
+
await store.fetchGiveUp(f.sha256, f.carrier, token, keep);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function partRoutes(ctx) {
|
|
301
|
+
/** The bytes of the first source that holds them; for an agent, `503` while they are being fetched. */
|
|
302
|
+
async function read(sources, sha256, agent, additional) {
|
|
303
|
+
const grants = [...sources];
|
|
304
|
+
for (const source of sources) {
|
|
305
|
+
const bytes = await ctx.store.part(sha256, source);
|
|
306
|
+
if (bytes)
|
|
307
|
+
return { status: 200, bytes };
|
|
308
|
+
}
|
|
309
|
+
// An already-held grant needs no publication lookup or audience hook. Only try additional grants
|
|
310
|
+
// after these bytes are unavailable; pending private fetches can still fall back to a publication.
|
|
311
|
+
if (additional) {
|
|
312
|
+
for await (const source of additional()) {
|
|
313
|
+
grants.push(source);
|
|
314
|
+
const bytes = await ctx.store.part(sha256, source);
|
|
315
|
+
if (bytes)
|
|
316
|
+
return { status: 200, bytes };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const now = iso(ctx.now());
|
|
320
|
+
for (const s of grants) {
|
|
321
|
+
if (agent &&
|
|
322
|
+
'carrier' in s &&
|
|
323
|
+
!(await ctx.store.fetchExhausted(sha256, s.carrier, now)) &&
|
|
324
|
+
!(await ctx.store.marked(GIVEN_UP, givenUp(sha256, s.carrier), now))) {
|
|
325
|
+
throw unavailable("Fetching this part from its carrier's server.", Math.ceil(PREFETCH_JITTER_MS / 1000));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw notFound();
|
|
329
|
+
}
|
|
330
|
+
/** Bytes held for each agent here whose message, as this viewer may see it, names the part. */
|
|
331
|
+
async function* namedFor(sha256, viewer) {
|
|
332
|
+
const self = ctx.config.origin;
|
|
333
|
+
const namers = await ctx.store.partNamers(sha256, 'published' in viewer ? { published: true } : viewer, self);
|
|
334
|
+
if (!('published' in viewer)) {
|
|
335
|
+
for (const { author } of namers) {
|
|
336
|
+
for (const source of await ctx.store.heldFor(sha256, author, self))
|
|
337
|
+
yield source;
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const reader = await viewer.published.reader();
|
|
342
|
+
// Try every unconditional grant before invoking an unrelated asynchronous audience policy.
|
|
343
|
+
// Yield lazily: read() can return held bytes without starting or retaining that policy work.
|
|
344
|
+
const unconditional = ({ message }) => audienceOf(message) === null || reader === message.from;
|
|
345
|
+
for (const { author } of namers.filter(unconditional)) {
|
|
346
|
+
for (const source of await ctx.store.heldFor(sha256, author, self))
|
|
347
|
+
yield source;
|
|
348
|
+
}
|
|
349
|
+
for (const { author, message } of namers.filter(namer => !unconditional(namer))) {
|
|
350
|
+
let admitted;
|
|
351
|
+
try {
|
|
352
|
+
admitted = await mayRead(ctx, message, viewer.published);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
// Only a failed audience decision grants nothing. Store and byte-read errors still propagate.
|
|
356
|
+
ctx.log('warn', 'part audience policy failed', { error: String(error) });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (admitted) {
|
|
360
|
+
for (const source of await ctx.store.heldFor(sha256, author, self))
|
|
361
|
+
yield source;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
method: 'PUT',
|
|
368
|
+
path: '/simon/parts/:sha256',
|
|
369
|
+
maxBody: ctx.config.limits.partBytes,
|
|
370
|
+
async handle(req) {
|
|
371
|
+
const agent = await authenticateAgent(ctx, req);
|
|
372
|
+
const sha256 = req.params.sha256;
|
|
373
|
+
if (!SHA256.test(sha256) || sha256Hex(req.body) !== sha256) {
|
|
374
|
+
throw invalid('The path is the lower-case hex SHA-256 of the body.', { field: 'sha256' });
|
|
375
|
+
}
|
|
376
|
+
ctx.rate.hit('upload', agent.address);
|
|
377
|
+
await ctx.hooks.upload({ address: agent.address, sha256, bytes: req.body });
|
|
378
|
+
await ctx.store.putPart(sha256, req.body, { uploader: agent.address }, iso(ctx.now()));
|
|
379
|
+
return { status: 200, json: { sha256, size: req.body.length } };
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
method: 'GET',
|
|
384
|
+
path: '/simon/parts/:sha256',
|
|
385
|
+
async handle(req) {
|
|
386
|
+
const sha256 = req.params.sha256;
|
|
387
|
+
if (!SHA256.test(sha256))
|
|
388
|
+
throw notFound();
|
|
389
|
+
// Hosted dispatch is based on public service routing, never a hidden account/key record.
|
|
390
|
+
const address = req.headers['simon-agent'];
|
|
391
|
+
let hosted = false;
|
|
392
|
+
/** Why whether the signer's domain names this server could not be known just now. */
|
|
393
|
+
let outage = null;
|
|
394
|
+
if (isAgentSigned(req) && isAddress(address)) {
|
|
395
|
+
const domain = domainOf(address);
|
|
396
|
+
hosted = ctx.config.hostedDomains.includes(domain) || ctx.config.leaseDomain === domain;
|
|
397
|
+
if (!hosted) {
|
|
398
|
+
hosted = await ctx.resolver
|
|
399
|
+
.servesHere(domain, () => ctx.rate.hit('lookup', clientKey(req.ip)))
|
|
400
|
+
.catch(error => {
|
|
401
|
+
asNobody(error);
|
|
402
|
+
if (error instanceof WireError && error.status === 503)
|
|
403
|
+
outage = error;
|
|
404
|
+
return false;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (hosted) {
|
|
409
|
+
const agent = await authenticateAgent(ctx, req).catch(error => {
|
|
410
|
+
if (error instanceof WireError && error.status === 401) {
|
|
411
|
+
throw unauthorized('bad_signature', 'This request is not authorized.', ctx.now());
|
|
412
|
+
}
|
|
413
|
+
throw error;
|
|
414
|
+
});
|
|
415
|
+
return read(await ctx.store.heldFor(sha256, agent.address, ctx.config.origin), sha256, true, () => namedFor(sha256, { published: asReader(agent.address) }));
|
|
416
|
+
}
|
|
417
|
+
// A failed foreign signature grants no private access; independent public grants still count.
|
|
418
|
+
const server = isServerSigned(req) ? await authenticateServer(ctx, req).catch(asNobody) : null;
|
|
419
|
+
const asker = await askerOf(ctx, req);
|
|
420
|
+
try {
|
|
421
|
+
return await read([], sha256, false, async function* () {
|
|
422
|
+
if (server)
|
|
423
|
+
yield* namedFor(sha256, { server });
|
|
424
|
+
yield* namedFor(sha256, { published: asker });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
// A signer that may be an agent here, whose own files may be among them, is not told a final 404
|
|
429
|
+
// while its domain cannot be resolved: it is answered as the lookup was, 503, whatever is asked.
|
|
430
|
+
if (outage && error instanceof WireError && error.status === 404)
|
|
431
|
+
throw outage;
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
/** A server whose signature fails, or whose document cannot be read for now, is nobody in particular. */
|
|
439
|
+
function asNobody(e) {
|
|
440
|
+
if (e instanceof WireError && [401, 429, 503].includes(e.status))
|
|
441
|
+
return null;
|
|
442
|
+
throw e;
|
|
443
|
+
}
|
package/dist/public.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What anyone may read, unsigned:
|
|
3
|
+
*
|
|
4
|
+
* GET /simon/server this server's document (spec section 4)
|
|
5
|
+
* GET /simon/agents/<address> an agent's document, signed by this server: for a retired address its
|
|
6
|
+
* retired document (spec section 4), its keys all revoked, `retired`, and
|
|
7
|
+
* the `unpairing` that retired it when one did; 404 for an unknown address,
|
|
8
|
+
* and 429 for any address once a client has asked for too many of those
|
|
9
|
+
* GET /simon/messages/... published messages, feeds and relations: published.ts
|
|
10
|
+
* GET /.well-known/simon {"server": <origin>}, served only when the origin's host is itself a domain
|
|
11
|
+
* this server hosts or leases on, so that domain resolves here by the
|
|
12
|
+
* well-known method (section 3) with no TXT record
|
|
13
|
+
* GET /health {"status":"ok"} while this server and its store answer, 503 when the store
|
|
14
|
+
* does not: for a load balancer's checks. No part of the protocol, so outside
|
|
15
|
+
* /simon/, and it says nothing else
|
|
16
|
+
*/
|
|
17
|
+
import { type AgentDocument, type KeyEntry, type ServerDocument } from '@agnt-sdk/simon-protocol';
|
|
18
|
+
import { type Context } from './context.ts';
|
|
19
|
+
import { type Route } from './http.ts';
|
|
20
|
+
import type { Agent } from './store/store.ts';
|
|
21
|
+
/**
|
|
22
|
+
* This server's document: the signing key, and the next key when one is announced. A key's `added` is
|
|
23
|
+
* when this server first published it, kept in the store, so it never moves.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ownDocument(ctx: Context): Promise<ServerDocument & {
|
|
26
|
+
syncDays: number;
|
|
27
|
+
history: false;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Every active key, then the newest retained revoked keys, within the total-entry bound. A
|
|
31
|
+
* key whose revocation is ahead of this clock remains active. An invalid legacy account with too many
|
|
32
|
+
* active keys fails closed; publication never silently drops an active key.
|
|
33
|
+
* The signed document's final byte bound is applied by agentDocument, independently of this count.
|
|
34
|
+
*/
|
|
35
|
+
export declare function listedKeys(agent: Agent, now: number): KeyEntry[];
|
|
36
|
+
/**
|
|
37
|
+
* An agent's document, signed with this server's key; a retired address's is its retired document (spec
|
|
38
|
+
* section 4), whose keys were all revoked when it retired, at or before `retired`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function agentDocument(ctx: Context, agent: Agent): AgentDocument;
|
|
41
|
+
/** Whether this server's own host is a domain it gives addresses on, so it answers that domain's well-known. */
|
|
42
|
+
export declare function answersWellKnown(config: Context['config']): boolean;
|
|
43
|
+
export declare function publicRoutes(ctx: Context, retiredAgentDocuments: 'public' | 'private'): Route[];
|
package/dist/public.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What anyone may read, unsigned:
|
|
3
|
+
*
|
|
4
|
+
* GET /simon/server this server's document (spec section 4)
|
|
5
|
+
* GET /simon/agents/<address> an agent's document, signed by this server: for a retired address its
|
|
6
|
+
* retired document (spec section 4), its keys all revoked, `retired`, and
|
|
7
|
+
* the `unpairing` that retired it when one did; 404 for an unknown address,
|
|
8
|
+
* and 429 for any address once a client has asked for too many of those
|
|
9
|
+
* GET /simon/messages/... published messages, feeds and relations: published.ts
|
|
10
|
+
* GET /.well-known/simon {"server": <origin>}, served only when the origin's host is itself a domain
|
|
11
|
+
* this server hosts or leases on, so that domain resolves here by the
|
|
12
|
+
* well-known method (section 3) with no TXT record
|
|
13
|
+
* GET /health {"status":"ok"} while this server and its store answer, 503 when the store
|
|
14
|
+
* does not: for a load balancer's checks. No part of the protocol, so outside
|
|
15
|
+
* /simon/, and it says nothing else
|
|
16
|
+
*/
|
|
17
|
+
import { canonicalize, keyToken, MAX_DOCUMENT_BYTES, MAX_DOCUMENT_KEYS, serverDocument, signAgentDocument, } from '@agnt-sdk/simon-protocol';
|
|
18
|
+
import { iso } from "./context.js";
|
|
19
|
+
import { notFound, unavailable } from "./errors.js";
|
|
20
|
+
import { json } from "./http.js";
|
|
21
|
+
/**
|
|
22
|
+
* This server's document: the signing key, and the next key when one is announced. A key's `added` is
|
|
23
|
+
* when this server first published it, kept in the store, so it never moves.
|
|
24
|
+
*/
|
|
25
|
+
export async function ownDocument(ctx) {
|
|
26
|
+
const now = iso(ctx.now());
|
|
27
|
+
const keys = [];
|
|
28
|
+
for (const k of [ctx.config.serverKey, ctx.config.nextServerKey]) {
|
|
29
|
+
if (!k)
|
|
30
|
+
continue;
|
|
31
|
+
const key = keyToken(k);
|
|
32
|
+
keys.push({ key, added: await ctx.store.valueOnce('server-key-added', key, now, null, now), revoked: null });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
...serverDocument(ctx.config.origin, keys, ctx.config.limits),
|
|
36
|
+
syncDays: ctx.config.syncDays,
|
|
37
|
+
history: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Every active key, then the newest retained revoked keys, within the total-entry bound. A
|
|
42
|
+
* key whose revocation is ahead of this clock remains active. An invalid legacy account with too many
|
|
43
|
+
* active keys fails closed; publication never silently drops an active key.
|
|
44
|
+
* The signed document's final byte bound is applied by agentDocument, independently of this count.
|
|
45
|
+
*/
|
|
46
|
+
export function listedKeys(agent, now) {
|
|
47
|
+
const active = agent.keys.filter(k => k.revoked === null || Date.parse(k.revoked) > now);
|
|
48
|
+
if (active.length > MAX_DOCUMENT_KEYS)
|
|
49
|
+
throw unavailable('This identity exceeds the key-document limit.');
|
|
50
|
+
const room = MAX_DOCUMENT_KEYS - active.length;
|
|
51
|
+
const retained = room > 0 ? agent.keys.filter(k => k.revoked !== null && Date.parse(k.revoked) <= now).slice(-room) : [];
|
|
52
|
+
const chosen = new Set([...active, ...retained]);
|
|
53
|
+
return agent.keys.filter(k => chosen.has(k)).map(({ key, added, revoked }) => ({ key, added, revoked }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* An agent's document, signed with this server's key; a retired address's is its retired document (spec
|
|
57
|
+
* section 4), whose keys were all revoked when it retired, at or before `retired`.
|
|
58
|
+
*/
|
|
59
|
+
export function agentDocument(ctx, agent) {
|
|
60
|
+
const now = ctx.now().getTime();
|
|
61
|
+
let keys = listedKeys(agent, now);
|
|
62
|
+
for (;;) {
|
|
63
|
+
const doc = signAgentDocument({
|
|
64
|
+
address: agent.address,
|
|
65
|
+
kind: agent.kind,
|
|
66
|
+
keys,
|
|
67
|
+
...(agent.retiredAt && {
|
|
68
|
+
retired: retiredAt(agent.retiredAt, keys),
|
|
69
|
+
...(agent.unpairing && { unpairing: agent.unpairing }),
|
|
70
|
+
}),
|
|
71
|
+
}, ctx.config.serverKey);
|
|
72
|
+
if (Buffer.byteLength(canonicalize(doc)) <= MAX_DOCUMENT_BYTES)
|
|
73
|
+
return doc;
|
|
74
|
+
const oldest = keys.find(k => k.revoked !== null && Date.parse(k.revoked) <= now);
|
|
75
|
+
if (!oldest)
|
|
76
|
+
throw unavailable('This identity exceeds the signed-document byte limit.');
|
|
77
|
+
keys = keys.filter(k => k !== oldest);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The `retired` a retired document states: when the address retired, or its latest revocation if later, so
|
|
82
|
+
* a key a process whose clock ran ahead revoked still falls at or before it and the document stays readable.
|
|
83
|
+
*/
|
|
84
|
+
function retiredAt(retired, keys) {
|
|
85
|
+
const latest = Math.max(Date.parse(retired), ...keys.map(k => (k.revoked === null ? 0 : Date.parse(k.revoked))));
|
|
86
|
+
return latest === Date.parse(retired) ? retired : new Date(latest).toISOString();
|
|
87
|
+
}
|
|
88
|
+
/** Whether this server's own host is a domain it gives addresses on, so it answers that domain's well-known. */
|
|
89
|
+
export function answersWellKnown(config) {
|
|
90
|
+
const host = new URL(config.origin).hostname;
|
|
91
|
+
return config.hostedDomains.includes(host) || config.leaseDomain === host;
|
|
92
|
+
}
|
|
93
|
+
export function publicRoutes(ctx, retiredAgentDocuments) {
|
|
94
|
+
const wellKnown = answersWellKnown(ctx.config)
|
|
95
|
+
? [{ method: 'GET', path: '/.well-known/simon', handle: async () => json({ server: ctx.config.origin }) }]
|
|
96
|
+
: [];
|
|
97
|
+
return [
|
|
98
|
+
...wellKnown,
|
|
99
|
+
{
|
|
100
|
+
method: 'GET',
|
|
101
|
+
path: '/health',
|
|
102
|
+
async handle() {
|
|
103
|
+
try {
|
|
104
|
+
await ctx.store.marked('health', 'probe', iso(ctx.now()));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
ctx.log('error', 'health: the store does not answer', { error: String(e) });
|
|
108
|
+
throw unavailable('This server cannot reach its store.');
|
|
109
|
+
}
|
|
110
|
+
return json({ status: 'ok' });
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
method: 'GET',
|
|
115
|
+
path: '/simon/server',
|
|
116
|
+
handle: async () => json(await ownDocument(ctx)),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
method: 'GET',
|
|
120
|
+
path: '/simon/agents/:address',
|
|
121
|
+
async handle(req) {
|
|
122
|
+
// Counted and refused before the lookup, so a client past its budget learns nothing of any address,
|
|
123
|
+
// and lookups in flight together cannot exceed it; an agent served is not a miss, and is given back.
|
|
124
|
+
ctx.rate.hitClient('agentMiss', req.ip);
|
|
125
|
+
const agent = await ctx.store.agentByAddress(req.params.address);
|
|
126
|
+
// Refuse private retirement evidence before projecting keys or unpairing, exactly like an absent row.
|
|
127
|
+
if (!agent || (retiredAgentDocuments === 'private' && agent.retiredAt))
|
|
128
|
+
throw notFound('No agent has this address here.');
|
|
129
|
+
ctx.rate.refundClient('agentMiss', req.ip);
|
|
130
|
+
return json(agentDocument(ctx, agent));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|