@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/server.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createServer`: the core assembled from its configuration, its store, the outside world (DNS, HTTP, a
|
|
3
|
+
* clock, a code sender) and any extensions. Everything outside is injectable, so tests run whole
|
|
4
|
+
* federations in one process on a fake network and a movable clock.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import type { Config } from './config.ts';
|
|
8
|
+
import { type MailboxSender } from './context.ts';
|
|
9
|
+
import { type Extension, type Log, type ServerApi } from './extensions.ts';
|
|
10
|
+
import { type Reply, type Request } from './http.ts';
|
|
11
|
+
import { type Dns } from './net/dns.ts';
|
|
12
|
+
import { type Http } from './net/fetch.ts';
|
|
13
|
+
import { type Store, type SweepCounts } from './store/store.ts';
|
|
14
|
+
export interface ServerOptions {
|
|
15
|
+
config: Config;
|
|
16
|
+
store: Store;
|
|
17
|
+
extensions?: Extension[];
|
|
18
|
+
/** DNS through c-ares (and DNS-over-HTTPS confirmation when configured) by default. */
|
|
19
|
+
dns?: Dns;
|
|
20
|
+
/** Outbound HTTP through the SSRF guard by default. */
|
|
21
|
+
http?: Http;
|
|
22
|
+
now?: () => Date;
|
|
23
|
+
log?: Log;
|
|
24
|
+
/** How codes, and the news of a recovery, reach an address. Default: logged in development, none in production. */
|
|
25
|
+
mailbox?: MailboxSender | null;
|
|
26
|
+
/** Run the worker loop in this process (default true). Tests turn it off and call `work()`. */
|
|
27
|
+
worker?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Public retirement evidence is host policy. Default: `config.retiredAgentDocuments`, which is `private`
|
|
30
|
+
* unless chosen otherwise; `public` serves signed retired documents and is an explicit opt-in.
|
|
31
|
+
*/
|
|
32
|
+
retiredAgentDocuments?: 'public' | 'private';
|
|
33
|
+
}
|
|
34
|
+
export interface SimonServer {
|
|
35
|
+
/** What extensions see. */
|
|
36
|
+
api: ServerApi;
|
|
37
|
+
/** Answer one request, in-process. */
|
|
38
|
+
handle(req: Request): Promise<Reply>;
|
|
39
|
+
/** The node:http listener, for a server of your own. */
|
|
40
|
+
listener: http.RequestListener;
|
|
41
|
+
/** Listen on a port (the configured one by default; 0 for any), over https when `config.tls` is set; resolves to the port. */
|
|
42
|
+
listen(port?: number, host?: string): Promise<number>;
|
|
43
|
+
/**
|
|
44
|
+
* Background work until it settles, waited for: arrivals offered, inbox events told, part fetches and
|
|
45
|
+
* deliveries attempted, as far as they are due now.
|
|
46
|
+
*/
|
|
47
|
+
work(): Promise<void>;
|
|
48
|
+
/** Remove what retention says has expired. */
|
|
49
|
+
sweep(): Promise<SweepCounts>;
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
export declare function createServer(opts: ServerOptions): SimonServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createServer`: the core assembled from its configuration, its store, the outside world (DNS, HTTP, a
|
|
3
|
+
* clock, a code sender) and any extensions. Everything outside is injectable, so tests run whole
|
|
4
|
+
* federations in one process on a fake network and a movable clock.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import https from 'node:https';
|
|
8
|
+
import { bodyTooLarge, WireError } from '@agnt-sdk/simon-protocol';
|
|
9
|
+
import { addKey, cancelRecovery, endGrant, getAccount, getRecovery, revokeKey, unpair } from "./account.js";
|
|
10
|
+
import { authenticateAgent } from "./auth.js";
|
|
11
|
+
import { iso } from "./context.js";
|
|
12
|
+
import { deliver } from "./deliver.js";
|
|
13
|
+
import { notFound, unavailable } from "./errors.js";
|
|
14
|
+
import { Hooks } from "./extensions.js";
|
|
15
|
+
import { fanOut, processArrivals, takeIntoInbox, tellInboxEvents } from "./fanout.js";
|
|
16
|
+
import { errorReply, nodeListener, Router } from "./http.js";
|
|
17
|
+
import { systemDns } from "./net/dns.js";
|
|
18
|
+
import { guardedHttp } from "./net/fetch.js";
|
|
19
|
+
import { loadPublicSuffixes } from "./net/psl.js";
|
|
20
|
+
import { Outbox } from "./outbox.js";
|
|
21
|
+
import { Fetcher, partRoutes } from "./parts.js";
|
|
22
|
+
import { ownDocument, publicRoutes } from "./public.js";
|
|
23
|
+
import { publishedRoutes } from "./published.js";
|
|
24
|
+
import { RateLimiter } from "./rate.js";
|
|
25
|
+
import { recover, register } from "./register.js";
|
|
26
|
+
import { Resolver } from "./resolver.js";
|
|
27
|
+
import { send, sendAs } from "./send.js";
|
|
28
|
+
import { sendSignal, signal } from "./signal.js";
|
|
29
|
+
import { Changes, sent, sync } from "./sync.js";
|
|
30
|
+
import { StoreCapacityError } from "./store/store.js";
|
|
31
|
+
import { WebhookPoster, webhookRoutes } from "./webhooks.js";
|
|
32
|
+
import { startWorker } from "./worker.js";
|
|
33
|
+
/** Uploads no message names are kept this long, so an agent can send the message after uploading. */
|
|
34
|
+
const UPLOAD_GRACE_MS = 48 * 3600_000;
|
|
35
|
+
/**
|
|
36
|
+
* Connections: headers within 10 s and a whole request within two minutes (a part of 25 MiB at 200 KiB/s),
|
|
37
|
+
* idle keep-alive connections closed after `config.keepAliveSeconds`, and at most `config.maxConnections`
|
|
38
|
+
* at once, so a client holding connections open (slowloris) cannot exhaust the server.
|
|
39
|
+
*/
|
|
40
|
+
const HTTP_OPTIONS = { headersTimeout: 10_000, requestTimeout: 120_000 };
|
|
41
|
+
/** Passes `work()` runs at most to let one burst of work settle. */
|
|
42
|
+
const SETTLE_PASSES = 10;
|
|
43
|
+
const defaultLog = (level, message, fields) => {
|
|
44
|
+
if (level !== 'debug')
|
|
45
|
+
console.error(JSON.stringify({ time: new Date().toISOString(), level, message, ...fields }));
|
|
46
|
+
};
|
|
47
|
+
export function createServer(opts) {
|
|
48
|
+
const retiredAgentDocuments = opts.retiredAgentDocuments === undefined
|
|
49
|
+
? (opts.config.retiredAgentDocuments ?? 'private')
|
|
50
|
+
: opts.retiredAgentDocuments;
|
|
51
|
+
if (retiredAgentDocuments !== 'public' && retiredAgentDocuments !== 'private')
|
|
52
|
+
throw new TypeError('retiredAgentDocuments is public or private.');
|
|
53
|
+
const { config, store } = opts;
|
|
54
|
+
const now = opts.now ?? (() => new Date());
|
|
55
|
+
const log = opts.log ?? defaultLog;
|
|
56
|
+
const dns = opts.dns ?? systemDns({ doh: config.dohResolvers, servers: config.dnsServers });
|
|
57
|
+
const outbound = opts.http ?? guardedHttp({ dns, dev: config.dev, trustedNetworks: config.trustedNetworks });
|
|
58
|
+
const deferred = new Set();
|
|
59
|
+
let worker = null;
|
|
60
|
+
let docCache = null;
|
|
61
|
+
const ctx = {
|
|
62
|
+
config,
|
|
63
|
+
store,
|
|
64
|
+
now,
|
|
65
|
+
log,
|
|
66
|
+
http: outbound,
|
|
67
|
+
resolver: new Resolver({
|
|
68
|
+
config,
|
|
69
|
+
store,
|
|
70
|
+
http: outbound,
|
|
71
|
+
dns,
|
|
72
|
+
now,
|
|
73
|
+
log,
|
|
74
|
+
ownDocument: () => (docCache ??= ownDocument(ctx)),
|
|
75
|
+
localAgent: async (address) => {
|
|
76
|
+
const agent = await store.agentByAddress(address);
|
|
77
|
+
return agent && !agent.retiredAt ? { kind: agent.kind, keys: agent.keys } : null;
|
|
78
|
+
},
|
|
79
|
+
publicSuffixes: config.publicSuffixFile ? loadPublicSuffixes(config.publicSuffixFile) : undefined,
|
|
80
|
+
chargeCarrier: carrier => ctx.rate.hit('carrierLookup', carrier),
|
|
81
|
+
}),
|
|
82
|
+
rate: new RateLimiter(config.rates, now, config.rateExemptNetworks),
|
|
83
|
+
hooks: new Hooks(opts.extensions ?? [], () => api),
|
|
84
|
+
mailbox: opts.mailbox !== undefined
|
|
85
|
+
? opts.mailbox
|
|
86
|
+
: config.dev
|
|
87
|
+
? async (address, { type, ...note }) =>
|
|
88
|
+
// Logged, never sent: a conformance run reads the codes from the `register code` lines.
|
|
89
|
+
log('info', `${type === 'code' ? 'register code' : type} (development: not sent)`, { address, ...note })
|
|
90
|
+
: null,
|
|
91
|
+
changes: new Changes(),
|
|
92
|
+
wake: () => worker?.wake(),
|
|
93
|
+
defer(task) {
|
|
94
|
+
const p = task()
|
|
95
|
+
.catch(e => log('error', 'deferred work failed', { error: String(e) }))
|
|
96
|
+
.finally(() => deferred.delete(p));
|
|
97
|
+
deferred.add(p);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const api = {
|
|
101
|
+
config,
|
|
102
|
+
store,
|
|
103
|
+
now,
|
|
104
|
+
log,
|
|
105
|
+
authenticate: req => authenticateAgent(ctx, req),
|
|
106
|
+
async admit(address, id) {
|
|
107
|
+
const record = await store.message(id);
|
|
108
|
+
return !!record && takeIntoInbox(ctx, address, record);
|
|
109
|
+
},
|
|
110
|
+
async offer(address, id) {
|
|
111
|
+
const record = await store.message(id);
|
|
112
|
+
return !!record && fanOut(ctx, record, [address], {});
|
|
113
|
+
},
|
|
114
|
+
send: (message, hints) => sendAs(ctx, message, hints),
|
|
115
|
+
retire: address => store.retireAgent(address, iso(now())),
|
|
116
|
+
};
|
|
117
|
+
const router = new Router();
|
|
118
|
+
const core = (method, path, handle) => router.add({ method, path, handle: req => handle(ctx, req) });
|
|
119
|
+
core('POST', '/simon/register', register);
|
|
120
|
+
core('GET', '/simon/account', getAccount);
|
|
121
|
+
core('POST', '/simon/recovery', recover);
|
|
122
|
+
core('GET', '/simon/recovery', getRecovery);
|
|
123
|
+
core('POST', '/simon/keys', addKey);
|
|
124
|
+
core('DELETE', '/simon/keys/:key', revokeKey);
|
|
125
|
+
core('DELETE', '/simon/grants/:id', endGrant);
|
|
126
|
+
core('POST', '/simon/recovery/cancel', cancelRecovery);
|
|
127
|
+
core('POST', '/simon/unpair', unpair);
|
|
128
|
+
core('POST', '/simon/send', send);
|
|
129
|
+
core('GET', '/simon/sync', sync);
|
|
130
|
+
core('GET', '/simon/sent/:id', sent);
|
|
131
|
+
core('POST', '/simon/deliver', deliver);
|
|
132
|
+
core('POST', '/simon/signal', signal);
|
|
133
|
+
core('POST', '/simon/send-signal', sendSignal);
|
|
134
|
+
for (const route of [
|
|
135
|
+
...publicRoutes(ctx, retiredAgentDocuments),
|
|
136
|
+
...publishedRoutes(ctx),
|
|
137
|
+
...partRoutes(ctx),
|
|
138
|
+
...webhookRoutes(ctx),
|
|
139
|
+
...ctx.hooks.routes,
|
|
140
|
+
]) {
|
|
141
|
+
router.add(route);
|
|
142
|
+
}
|
|
143
|
+
const bodyLimit = (method, path) => router.match(method, path)?.route.maxBody ?? config.limits.requestBodyBytes;
|
|
144
|
+
async function handle(req) {
|
|
145
|
+
try {
|
|
146
|
+
ctx.rate.hitClient('request', req.ip);
|
|
147
|
+
const match = router.match(req.method, req.path);
|
|
148
|
+
if (!match)
|
|
149
|
+
throw notFound();
|
|
150
|
+
const limit = match.route.maxBody ?? config.limits.requestBodyBytes;
|
|
151
|
+
if (req.body.length > limit)
|
|
152
|
+
throw bodyTooLarge(limit);
|
|
153
|
+
return await match.route.handle({ ...req, params: match.params }, api);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
if (e instanceof StoreCapacityError)
|
|
157
|
+
return errorReply(unavailable('The server is temporarily at capacity.', 60));
|
|
158
|
+
if (e instanceof WireError)
|
|
159
|
+
return errorReply(e);
|
|
160
|
+
log('error', 'request failed', { method: req.method, path: req.path, error: String(e?.stack ?? e) });
|
|
161
|
+
return errorReply(new WireError(500, 'internal', 'This server failed; try again later.'));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const outbox = new Outbox(ctx);
|
|
165
|
+
const fetcher = new Fetcher(ctx);
|
|
166
|
+
const webhooks = new WebhookPoster(ctx);
|
|
167
|
+
const settle = async () => {
|
|
168
|
+
while (deferred.size)
|
|
169
|
+
await Promise.all(deferred);
|
|
170
|
+
};
|
|
171
|
+
const drain = async (step) => {
|
|
172
|
+
let total = 0;
|
|
173
|
+
for (let n; (n = await step(ctx)) > 0;)
|
|
174
|
+
total += n;
|
|
175
|
+
return total;
|
|
176
|
+
};
|
|
177
|
+
/** Every stage of background work, each on its own: one that fails stops no other. Returns the work begun. */
|
|
178
|
+
const pass = async () => {
|
|
179
|
+
let begun = 0;
|
|
180
|
+
const stages = [
|
|
181
|
+
() => drain(processArrivals),
|
|
182
|
+
() => drain(tellInboxEvents),
|
|
183
|
+
() => fetcher.tick(),
|
|
184
|
+
() => outbox.tick(),
|
|
185
|
+
() => webhooks.tick(),
|
|
186
|
+
];
|
|
187
|
+
for (const stage of stages) {
|
|
188
|
+
begun += await stage().catch(e => (log('error', 'worker stage failed', { error: String(e) }), 0));
|
|
189
|
+
}
|
|
190
|
+
return begun;
|
|
191
|
+
};
|
|
192
|
+
const sweep = () => store.sweep(iso(now()), iso(new Date(now().getTime() - UPLOAD_GRACE_MS)));
|
|
193
|
+
if (opts.worker ?? true) {
|
|
194
|
+
worker = startWorker(async () => void (await pass()), async () => void (await sweep()), e => log('error', 'worker pass failed', { error: String(e) }));
|
|
195
|
+
}
|
|
196
|
+
const listener = nodeListener({ bodyLimit, handle }, config);
|
|
197
|
+
let httpServer = null;
|
|
198
|
+
return {
|
|
199
|
+
api,
|
|
200
|
+
handle,
|
|
201
|
+
listener,
|
|
202
|
+
listen(port = config.port, host = config.listenHost ?? undefined) {
|
|
203
|
+
const options = { ...HTTP_OPTIONS, keepAliveTimeout: config.keepAliveSeconds * 1000 };
|
|
204
|
+
httpServer = config.tls
|
|
205
|
+
? https.createServer({ ...options, cert: config.tls.cert, key: config.tls.key }, listener)
|
|
206
|
+
: http.createServer(options, listener);
|
|
207
|
+
httpServer.maxConnections = config.maxConnections;
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
httpServer.once('error', reject);
|
|
210
|
+
httpServer.listen(port, host, () => resolve(httpServer.address().port));
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
async work() {
|
|
214
|
+
for (let i = 0; i < SETTLE_PASSES; i++) {
|
|
215
|
+
const begun = await pass();
|
|
216
|
+
await Promise.all([outbox.idle(), fetcher.idle(), webhooks.idle(), settle()]);
|
|
217
|
+
if (!begun)
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
sweep,
|
|
222
|
+
async close() {
|
|
223
|
+
ctx.changes.close();
|
|
224
|
+
await worker?.stop();
|
|
225
|
+
await new Promise(resolve => {
|
|
226
|
+
if (!httpServer)
|
|
227
|
+
return resolve();
|
|
228
|
+
httpServer.close(() => resolve());
|
|
229
|
+
httpServer.closeAllConnections();
|
|
230
|
+
});
|
|
231
|
+
await Promise.all([outbox.idle(), fetcher.idle(), webhooks.idle(), settle()]);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
package/dist/signal.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Context } from './context.ts';
|
|
2
|
+
import { type Reply, type Request } from './http.ts';
|
|
3
|
+
export declare function signal(ctx: Context, req: Request): Promise<Reply>;
|
|
4
|
+
/** A signed-in client asks its server to carry a transient signal to parties of a held message. */
|
|
5
|
+
export declare function sendSignal(ctx: Context, req: Request): Promise<Reply>;
|
package/dist/signal.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /simon/signal` (spec section 6.4): a short-lived note from another server about a message, checked in
|
|
3
|
+
* the section's order, answering the first failure:
|
|
4
|
+
*
|
|
5
|
+
* 1-3 body size and parse, shape, and the signal for this server, with no lookup (parseSignal)
|
|
6
|
+
* 4 the `from` domain resolves to another server (403 otherwise)
|
|
7
|
+
* 5 the signal verifies with that server's keys valid now
|
|
8
|
+
* 6 every recipient on a domain served here
|
|
9
|
+
* 7 `at` within 300 seconds
|
|
10
|
+
* 8 202, whatever happens next
|
|
11
|
+
*
|
|
12
|
+
* After the answer, which signals reach whom is this server's own policy (any server may drop any signal):
|
|
13
|
+
* this one passes a signal to a recipient here who holds the message it is about, from that message's author
|
|
14
|
+
* or one of its recipients, and drops the rest (docs/REFERENCE-SERVER.md). Nothing
|
|
15
|
+
* keeps it beyond five minutes: it enters the recipient's sync stream and is told to the extensions' `signal` listeners, which show it now or never, and is then gone.
|
|
16
|
+
*/
|
|
17
|
+
import { canonicalBytes, checkServed, checkSignalFreshness, domainOf, forbidden, invalid, isAddress, isHeaderName, isJsonObject, parseJson, signSignal, parseSignal, signedByAny, signingBytes, } from '@agnt-sdk/simon-protocol';
|
|
18
|
+
import { authenticateAgent } from "./auth.js";
|
|
19
|
+
import { conflict } from "./errors.js";
|
|
20
|
+
import { iso, later } from "./context.js";
|
|
21
|
+
import { servedDomains } from "./deliver.js";
|
|
22
|
+
import { json } from "./http.js";
|
|
23
|
+
import { clientKey, networkKey } from "./net/ip.js";
|
|
24
|
+
import { changed } from "./sync.js";
|
|
25
|
+
const ACCEPTED = { accepted: true };
|
|
26
|
+
export async function signal(ctx, req) {
|
|
27
|
+
const client = clientKey(req.ip);
|
|
28
|
+
const charge = () => ctx.rate.hit('lookup', client);
|
|
29
|
+
const { origin, dev: allowHttp, limits } = ctx.config;
|
|
30
|
+
const s = parseSignal(req.body, { origin, allowHttp }, limits);
|
|
31
|
+
const domain = domainOf(s.from);
|
|
32
|
+
const peer = await ctx.resolver.peer(domain, charge);
|
|
33
|
+
if (peer.outcome !== 'server' || peer.server === origin) {
|
|
34
|
+
throw forbidden('bad_signature', `${domain} names no other SIMON server, so no signal from it verifies.`);
|
|
35
|
+
}
|
|
36
|
+
const failures = `${client} ${peer.server}`;
|
|
37
|
+
ctx.rate.check('badSignature', failures);
|
|
38
|
+
const bytes = signingBytes('SIMON-SIGNAL', s);
|
|
39
|
+
const signs = (doc) => !!doc && signedByAny(doc.keys, ctx.now(), bytes, s.signature);
|
|
40
|
+
if (!signs(peer.document) && !signs(await ctx.resolver.serverDocument(peer.server, { refresh: true, charge }))) {
|
|
41
|
+
ctx.rate.hit('badSignature', failures);
|
|
42
|
+
throw forbidden('bad_signature', `The signal is not signed by a key of ${peer.server} valid now.`);
|
|
43
|
+
}
|
|
44
|
+
ctx.rate.hit('deliver', peer.server);
|
|
45
|
+
const carrier = {
|
|
46
|
+
carrier: peer.server,
|
|
47
|
+
network: networkKey(req.ip),
|
|
48
|
+
charge: () => ctx.rate.hit('carrierLookup', peer.server),
|
|
49
|
+
};
|
|
50
|
+
checkServed(s.to, await servedDomains(ctx, s.to, carrier), 'to');
|
|
51
|
+
checkSignalFreshness(s, ctx.now());
|
|
52
|
+
ctx.defer(() => pass(ctx, s));
|
|
53
|
+
return json(ACCEPTED, 202);
|
|
54
|
+
}
|
|
55
|
+
/** Tell the listeners of each recipient here who holds the message the signal is about, from a party to it. */
|
|
56
|
+
async function pass(ctx, s) {
|
|
57
|
+
const record = await ctx.store.message(s.about);
|
|
58
|
+
if (!record || !(record.message.from === s.from || record.message.to.includes(s.from)))
|
|
59
|
+
return;
|
|
60
|
+
for (const recipient of s.to) {
|
|
61
|
+
const agent = await ctx.store.agentByAddress(recipient);
|
|
62
|
+
if (!agent || agent.retiredAt)
|
|
63
|
+
continue;
|
|
64
|
+
if (record.message.from === recipient || (await ctx.store.inInbox(recipient, record.id))) {
|
|
65
|
+
await ctx.store.holdSignal(recipient, { type: s.type, from: s.from, about: s.about, at: s.at }, later(ctx.now(), 300_000), iso(ctx.now()));
|
|
66
|
+
await changed(ctx, recipient);
|
|
67
|
+
await ctx.hooks.emit('signal', { recipient, signal: s });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** A signed-in client asks its server to carry a transient signal to parties of a held message. */
|
|
72
|
+
export async function sendSignal(ctx, req) {
|
|
73
|
+
const agent = await authenticateAgent(ctx, req);
|
|
74
|
+
const body = parseJson(req.body);
|
|
75
|
+
if (!isJsonObject(body) ||
|
|
76
|
+
!isHeaderName(body.type) ||
|
|
77
|
+
typeof body.about !== 'string' ||
|
|
78
|
+
!/^[0-9a-f]{64}$/.test(body.about) ||
|
|
79
|
+
!Array.isArray(body.to) ||
|
|
80
|
+
!body.to.length ||
|
|
81
|
+
body.to.length > ctx.config.limits.recipients ||
|
|
82
|
+
!body.to.every(isAddress) ||
|
|
83
|
+
new Set(body.to).size !== body.to.length)
|
|
84
|
+
throw invalid('A signal names a type, message id and distinct recipient addresses.');
|
|
85
|
+
const type = body.type, about = body.about, to = body.to;
|
|
86
|
+
ctx.rate.hit('send', agent.address);
|
|
87
|
+
const record = await ctx.store.message(about);
|
|
88
|
+
if (!record || !(record.message.from === agent.address || (await ctx.store.inInbox(agent.address, about)))) {
|
|
89
|
+
return json(ACCEPTED, 202);
|
|
90
|
+
}
|
|
91
|
+
const parties = new Set([record.message.from, ...record.message.to]);
|
|
92
|
+
const recipients = to.filter(address => parties.has(address) && address !== agent.address);
|
|
93
|
+
if (!(await ctx.resolver.servesHere(domainOf(agent.address)))) {
|
|
94
|
+
throw conflict('address_not_served', 'This address is no longer served here.');
|
|
95
|
+
}
|
|
96
|
+
const at = iso(ctx.now());
|
|
97
|
+
// Validate the complete signed shape and canonical size before accepting, without a recipient lookup.
|
|
98
|
+
const sample = signSignal({ type, from: agent.address, to, about, at, server: ctx.config.origin }, ctx.config.serverKey);
|
|
99
|
+
parseSignal(canonicalBytes(sample), { origin: ctx.config.origin, allowHttp: ctx.config.dev }, ctx.config.limits);
|
|
100
|
+
ctx.defer(async () => {
|
|
101
|
+
const groups = new Map();
|
|
102
|
+
const charge = () => ctx.rate.hit('lookup', clientKey(req.ip));
|
|
103
|
+
for (const recipient of recipients) {
|
|
104
|
+
try {
|
|
105
|
+
const resolved = await ctx.resolver.resolve(domainOf(recipient), { charge });
|
|
106
|
+
if (resolved.outcome !== 'server')
|
|
107
|
+
continue;
|
|
108
|
+
const group = groups.get(resolved.server) ?? [];
|
|
109
|
+
group.push(recipient);
|
|
110
|
+
groups.set(resolved.server, group);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* Transient signals may be dropped. */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [server, to] of groups) {
|
|
117
|
+
try {
|
|
118
|
+
const s = signSignal({ type, from: agent.address, to, about, at, server }, ctx.config.serverKey);
|
|
119
|
+
if (server === ctx.config.origin)
|
|
120
|
+
await pass(ctx, s);
|
|
121
|
+
else
|
|
122
|
+
await ctx.http.request({
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: `${server}/simon/signal`,
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: canonicalBytes(s),
|
|
127
|
+
maxBytes: 16_384,
|
|
128
|
+
timeoutMs: 10_000,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* No retries: a signal is useful only now. */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return json(ACCEPTED, 202);
|
|
137
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Store, RetractionCapacity } from './store.ts';
|
|
2
|
+
export declare function storeContract(name: string, make: () => Store | Promise<Store>): void;
|
|
3
|
+
/** Host admission boundary tests; factories supply the same options to their adapter constructor. */
|
|
4
|
+
export declare function retractionCapacityContract(name: string, make: (limits: Partial<RetractionCapacity>) => Store | Promise<Store>): void;
|