@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.
Files changed (78) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +317 -2
  4. package/dist/account.d.ts +59 -0
  5. package/dist/account.js +244 -0
  6. package/dist/auth.d.ts +42 -0
  7. package/dist/auth.js +96 -0
  8. package/dist/config.d.ts +219 -0
  9. package/dist/config.js +300 -0
  10. package/dist/context.d.ts +57 -0
  11. package/dist/context.js +2 -0
  12. package/dist/continuity.d.ts +35 -0
  13. package/dist/continuity.js +74 -0
  14. package/dist/deliver.d.ts +22 -0
  15. package/dist/deliver.js +208 -0
  16. package/dist/errors.d.ts +11 -0
  17. package/dist/errors.js +11 -0
  18. package/dist/extensions.d.ts +216 -0
  19. package/dist/extensions.js +75 -0
  20. package/dist/fanout.d.ts +32 -0
  21. package/dist/fanout.js +79 -0
  22. package/dist/http.d.ts +69 -0
  23. package/dist/http.js +112 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.js +12 -0
  26. package/dist/main.d.ts +2 -0
  27. package/dist/main.js +41 -0
  28. package/dist/net/dns.d.ts +31 -0
  29. package/dist/net/dns.js +100 -0
  30. package/dist/net/fetch.d.ts +48 -0
  31. package/dist/net/fetch.js +173 -0
  32. package/dist/net/ip.d.ts +38 -0
  33. package/dist/net/ip.js +159 -0
  34. package/dist/net/psl.d.ts +22 -0
  35. package/dist/net/psl.js +91 -0
  36. package/dist/net/public-suffixes.d.ts +10 -0
  37. package/dist/net/public-suffixes.js +6885 -0
  38. package/dist/notice.d.ts +5 -0
  39. package/dist/notice.js +61 -0
  40. package/dist/outbox.d.ts +14 -0
  41. package/dist/outbox.js +356 -0
  42. package/dist/parts.d.ts +29 -0
  43. package/dist/parts.js +443 -0
  44. package/dist/public.d.ts +43 -0
  45. package/dist/public.js +134 -0
  46. package/dist/published.d.ts +3 -0
  47. package/dist/published.js +140 -0
  48. package/dist/rate.d.ts +28 -0
  49. package/dist/rate.js +77 -0
  50. package/dist/readers.d.ts +31 -0
  51. package/dist/readers.js +48 -0
  52. package/dist/register.d.ts +4 -0
  53. package/dist/register.js +401 -0
  54. package/dist/resolver.d.ts +140 -0
  55. package/dist/resolver.js +386 -0
  56. package/dist/send.d.ts +30 -0
  57. package/dist/send.js +216 -0
  58. package/dist/server.d.ts +52 -0
  59. package/dist/server.js +234 -0
  60. package/dist/signal.d.ts +5 -0
  61. package/dist/signal.js +137 -0
  62. package/dist/store/contract.d.ts +4 -0
  63. package/dist/store/contract.js +1177 -0
  64. package/dist/store/sqlite-migration.d.ts +82 -0
  65. package/dist/store/sqlite-migration.js +1019 -0
  66. package/dist/store/sqlite.d.ts +108 -0
  67. package/dist/store/sqlite.js +992 -0
  68. package/dist/store/store.d.ts +400 -0
  69. package/dist/store/store.js +23 -0
  70. package/dist/sync.d.ts +19 -0
  71. package/dist/sync.js +199 -0
  72. package/dist/testing.d.ts +38 -0
  73. package/dist/testing.js +87 -0
  74. package/dist/webhooks.d.ts +10 -0
  75. package/dist/webhooks.js +162 -0
  76. package/dist/worker.d.ts +15 -0
  77. package/dist/worker.js +33 -0
  78. package/package.json +57 -4
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `POST /simon/deliver` (spec section 6.3): an envelope from another server, checked in exactly the
3
+ * section's order, answering the first failure:
4
+ *
5
+ * 1-5 body size and parse, envelope shape, canonical message, message rules, and the envelope for this
6
+ * server with its recipients in `to`, all without a lookup (parseEnvelope)
7
+ * 6 the `from` domain resolves to another server (403 otherwise)
8
+ * 7 the envelope, and its claims, verify with that server's keys valid now, by a key trusted under
9
+ * continuity
10
+ * 8 every recipient on a domain served here: only now, with the carrier known, may that need a lookup
11
+ * 9 a repeat for recipients all accepted here in the last 168 hours is 202
12
+ * 10 `sentAt` fresh
13
+ * 11 the author's agent document readable, and the message verifies at its `sentAt`
14
+ * 12 202: accepted once per (id, recipient)
15
+ *
16
+ * Deliver is the one call anyone can make that sends this server out to the network, so the one lookup it
17
+ * makes before the carrier is known, the `from` domain, is counted against the client's network (`lookup`).
18
+ * Every lookup after it is the carrier's: its recipients' domains and its claims count against its
19
+ * `carrierLookup` rate, and those and its author's document run in the carrier's share of a pool of their
20
+ * own, never in the one the server's own sending uses. Recipients' domains are resolved side by side, as
21
+ * many at once as the carrier's share allows, and none is started past RECIPIENT_LOOKUPS_MS, so an envelope
22
+ * naming many slow domains holds a delivery a bounded time. Before step 7, the (client network, claimed
23
+ * server) pair must have signature failures to spare: a sender whose signatures keep failing costs one counter read, not a
24
+ * verification. Every delivery past step 7, a repeat as much as a new one, counts against the carrier's
25
+ * `deliver` rate. Accepting stores the message whoever the recipients are, and queues the fetch of its parts
26
+ * (spec section 7.2), so the answer, its timing and the fetches say nothing about them; offering it to them
27
+ * happens afterwards, in the worker.
28
+ */
29
+ import crypto from 'node:crypto';
30
+ import { checkAuthorSignature, checkEnvelopeBeside, checkFreshness, checkServed, claimsBytes, domainOf, forbidden, isRepeat, judgeClaims, messageId, parseEnvelope, parseTimestamp, REPEAT_WINDOW_MS, signedByAny, signingBytes, verifyMessage, WireError, } from '@agnt-sdk/simon-protocol';
31
+ import { iso, later } from "./context.js";
32
+ import { arrivalsFor } from "./fanout.js";
33
+ import { queuePrefetch } from "./parts.js";
34
+ import { json } from "./http.js";
35
+ import { clientKey, networkKey } from "./net/ip.js";
36
+ import { unavailable } from "./errors.js";
37
+ import { isRefusal } from "./resolver.js";
38
+ const DAY_MS = 86_400_000;
39
+ const ACCEPTED = { accepted: true };
40
+ export async function deliver(ctx, req) {
41
+ const now = ctx.now();
42
+ const client = clientKey(req.ip);
43
+ const charge = () => ctx.rate.hit('lookup', client);
44
+ // Steps 1 to 5 need no lookup, so an envelope meant for another server, or for no one here, costs none.
45
+ const { origin, dev: allowHttp } = ctx.config;
46
+ const envelope = parseEnvelope(req.body, ctx.config.limits, { origin, allowHttp });
47
+ checkEnvelopeBeside(envelope, ctx.config.envelopeExtrasBytes);
48
+ const m = envelope.message;
49
+ const domain = domainOf(m.from);
50
+ let cached = ctx.resolver.isCached(domain);
51
+ let peer = await ctx.resolver.peer(domain, charge);
52
+ const noServer = () => forbidden('bad_signature', `${domain} names no other SIMON server, so no envelope from it verifies.`);
53
+ // Even a cached absence or our own old route can hide a move to the actual carrying server.
54
+ if (cached && (peer.outcome !== 'server' || peer.server === ctx.config.origin)) {
55
+ peer = (await reresolve(ctx, domain, () => ctx.resolver.peer(domain, charge, { fresh: true }))) ?? peer;
56
+ cached = false;
57
+ }
58
+ if (peer.outcome !== 'server' || peer.server === ctx.config.origin)
59
+ throw noServer();
60
+ let server = peer.server;
61
+ const failures = `${client} ${server}`;
62
+ ctx.rate.check('badSignature', failures);
63
+ const fail = (e) => {
64
+ if (e.code === 'bad_signature')
65
+ ctx.rate.hit('badSignature', failures);
66
+ return e;
67
+ };
68
+ let signed = signer(envelope, peer.document, now) ??
69
+ signer(envelope, await ctx.resolver.serverDocument(server, { refresh: true, charge }), now);
70
+ // Step 7's last chance: a domain that just moved its server is found at the new one. Resolved and read
71
+ // again without the cache, when the resolution came from it, at most once a minute for one domain (a mark
72
+ // in the store, which every process shares); a re-resolution that fails for now is 503.
73
+ const again = !signed && cached ? await reresolve(ctx, domain, () => ctx.resolver.peer(domain, charge, { fresh: true })) : null;
74
+ if (again) {
75
+ peer = again;
76
+ if (peer.outcome !== 'server' || peer.server === ctx.config.origin)
77
+ throw fail(noServer());
78
+ server = peer.server;
79
+ signed = signer(envelope, peer.document, now);
80
+ }
81
+ if (!signed) {
82
+ throw fail(forbidden('bad_signature', `The envelope is not signed by a key of ${server} valid now.`));
83
+ }
84
+ await ctx.resolver.trustSigner(domain, server, signed.key, signed.document);
85
+ ctx.rate.hit('deliver', server);
86
+ const network = networkKey(req.ip);
87
+ const carrier = { carrier: server, network, charge: () => ctx.rate.hit('carrierLookup', server) };
88
+ checkServed(envelope.recipients, await servedDomains(ctx, envelope.recipients, carrier));
89
+ const id = messageId(m);
90
+ const accepted = new Set();
91
+ for (const r of envelope.recipients)
92
+ if (await ctx.store.marked('accepted', `${id}\n${r}`, iso(now)))
93
+ accepted.add(r);
94
+ if (isRepeat(envelope, accepted, now))
95
+ return json(ACCEPTED, 202);
96
+ checkFreshness(m, now);
97
+ const authorOf = (refresh) => ctx.resolver.agentKeys(m.from, server, refresh, { carrier: server, network });
98
+ let author = await authorOf(false);
99
+ if (!author || !verifyMessage(m, author.keys))
100
+ author = await authorOf(true);
101
+ if (!author)
102
+ throw fail(forbidden('bad_signature', `${server} serves no agent document for ${m.from}.`));
103
+ try {
104
+ checkAuthorSignature(m, author.keys);
105
+ }
106
+ catch (e) {
107
+ throw e instanceof WireError ? fail(e) : e;
108
+ }
109
+ const fresh = envelope.recipients.filter(r => !accepted.has(r));
110
+ const record = await ctx.store.putMessage({
111
+ id,
112
+ message: m,
113
+ carrier: server,
114
+ claims: await judgeClaims(m, envelope.claims, author.kind, server, d => ctx.resolver.resolve(d, { charge: carrier })),
115
+ signedClaims: envelope.claims ? { claims: envelope.claims, signature: envelope.claimsSignature } : null,
116
+ receivedAt: iso(now),
117
+ expiresAt: later(now, ctx.config.retentionDays * DAY_MS),
118
+ }, { arrivals: arrivalsFor(id, fresh, envelope.hints ?? {}, now) });
119
+ await queuePrefetch(ctx, record);
120
+ const until = new Date(parseTimestamp(m.sentAt) + REPEAT_WINDOW_MS).toISOString();
121
+ for (const r of fresh)
122
+ await ctx.store.mark('accepted', `${id}\n${r}`, until, iso(now));
123
+ await ctx.resolver.vouch(domain, signed.document);
124
+ ctx.wake();
125
+ return json(ACCEPTED, 202);
126
+ }
127
+ /**
128
+ * The carrier key that signed the envelope, and its claims, among `document`'s keys valid now, or null. The
129
+ * signed bytes are made once, however many keys the document lists.
130
+ */
131
+ function signer(e, document, now) {
132
+ if (!document)
133
+ return null;
134
+ const envelopeBytes = signingBytes('SIMON-ENVELOPE', e);
135
+ const claims = e.claims && claimsBytes(e.claims, messageId(e.message));
136
+ const key = document.keys.find(k => signedByAny([k], now, envelopeBytes, e.signature));
137
+ if (!key || (claims && !signedByAny(document.keys, now, claims, e.claimsSignature)))
138
+ return null;
139
+ return { key: key.key, document };
140
+ }
141
+ /** How long step 8 may go on starting lookups of recipients' domains; a lookup underway ends by its own deadline. */
142
+ export const RECIPIENT_LOOKUPS_MS = 20_000;
143
+ /** A domain is resolved again without the cache, before a 403 at step 7 or a 400 at step 8, this often at most. */
144
+ export const RERESOLVE_EVERY_MS = 60_000;
145
+ /**
146
+ * Resolve a domain again without the cache with `lookup`, at most once a minute for one domain across processes
147
+ * (a store value only one caller takes), or answer null without looking when that minute's re-resolution is
148
+ * taken. A call refused before it made any lookup (the payer out of room or rate) gives the minute back, so
149
+ * a forger out of budget cannot use it up for everyone; one refused after a lookup was made keeps it.
150
+ */
151
+ export async function reresolve(ctx, domain, lookup) {
152
+ const token = crypto.randomUUID();
153
+ const until = later(ctx.now(), RERESOLVE_EVERY_MS);
154
+ if ((await ctx.store.valueOnce('reresolve', domain, token, until, iso(ctx.now()))) !== token)
155
+ return null;
156
+ const tally = { made: false };
157
+ try {
158
+ return await ctx.resolver.tallied(tally, lookup);
159
+ }
160
+ catch (e) {
161
+ if (isRefusal(e) && !tally.made)
162
+ await ctx.store.releaseValue('reresolve', domain, token);
163
+ throw e;
164
+ }
165
+ }
166
+ /**
167
+ * Step 8 for deliver and signal: whether each address's domain is served here, hosted or leased on, or
168
+ * resolving here. The domains needing a lookup are resolved side by side, as many at once as the
169
+ * payer's free share (at most MAX_LOOKUPS_PER_CARRIER), each paid for by `payer`; once RECIPIENT_LOOKUPS_MS
170
+ * have passed no more are started, and the answer is 503.
171
+ */
172
+ export async function servedDomains(ctx, addresses, payer) {
173
+ const { hostedDomains, leaseDomain } = ctx.config;
174
+ const domains = [...new Set(addresses.map(domainOf))];
175
+ const served = new Set(domains.filter(d => hostedDomains.includes(d) || d === leaseDomain));
176
+ const queue = domains.filter(d => !served.has(d));
177
+ const wasCached = new Set(queue.filter(d => ctx.resolver.isCached(d)));
178
+ const deadline = ctx.now().getTime() + RECIPIENT_LOOKUPS_MS;
179
+ let stopped = false;
180
+ const lookups = async () => {
181
+ for (let d; !stopped && (d = queue.shift()) !== undefined;) {
182
+ if (ctx.now().getTime() > deadline)
183
+ throw unavailable("Could not resolve the recipients' domains in time.");
184
+ if (await ctx.resolver.servesHere(d, payer))
185
+ served.add(d);
186
+ }
187
+ };
188
+ // As many side by side as the payer's free share allows now, so its other deliveries' lookups underway
189
+ // never make this one refuse itself; at least one, which a full share refuses for now (503).
190
+ const width = Math.max(1, ctx.resolver.room(payer));
191
+ const workers = Array.from({ length: Math.min(queue.length, width) }, lookups);
192
+ await Promise.all(workers).catch(e => {
193
+ stopped = true;
194
+ throw e;
195
+ });
196
+ // Before answering 400 for a domain whose answer came from the cache, it is resolved again without it, at
197
+ // most once a minute for one domain, so a domain that has just moved here is found (spec section 6.3 step 8);
198
+ // within the same deadline, past which the answer is 503.
199
+ for (const d of wasCached) {
200
+ if (served.has(d))
201
+ continue;
202
+ if (ctx.now().getTime() > deadline)
203
+ throw unavailable("Could not resolve the recipients' domains in time.");
204
+ if (await reresolve(ctx, d, () => ctx.resolver.servesHere(d, payer, true)))
205
+ served.add(d);
206
+ }
207
+ return (d) => served.has(d);
208
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Errors in the protocol's shape (spec section 9, RFC 9457) beyond the ones @agnt-sdk/simon-protocol makes. A 429 or
3
+ * 503 says when to retry in `details.retryAfter`, in seconds, which also goes out as Retry-After.
4
+ */
5
+ import { WireError } from '@agnt-sdk/simon-protocol';
6
+ export declare const notFound: (what?: string) => WireError;
7
+ export declare const conflict: (code: string, message: string, details?: Record<string, unknown>) => WireError;
8
+ export declare const badRequest: (code: string, message: string, details?: Record<string, unknown>) => WireError;
9
+ export declare const rateLimited: (retryAfter: number) => WireError;
10
+ /** A lookup or fetch this server needed failed just now. */
11
+ export declare const unavailable: (message: string, retryAfter?: number) => WireError;
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Errors in the protocol's shape (spec section 9, RFC 9457) beyond the ones @agnt-sdk/simon-protocol makes. A 429 or
3
+ * 503 says when to retry in `details.retryAfter`, in seconds, which also goes out as Retry-After.
4
+ */
5
+ import { WireError } from '@agnt-sdk/simon-protocol';
6
+ export const notFound = (what = 'Nothing is here.') => new WireError(404, 'not_found', what);
7
+ export const conflict = (code, message, details) => new WireError(409, code, message, details);
8
+ export const badRequest = (code, message, details) => new WireError(400, code, message, details);
9
+ export const rateLimited = (retryAfter) => new WireError(429, 'rate_limited', `Too many requests; retry in ${retryAfter} s.`, { retryAfter });
10
+ /** A lookup or fetch this server needed failed just now. */
11
+ export const unavailable = (message, retryAfter = 30) => new WireError(503, 'unavailable', message, { retryAfter });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The extension seam: how an application adds a directory, recipient policy, an owner window or a bridge
3
+ * without editing the core. An extension is a plain object; the server takes an array of them, and every
4
+ * hook is the list of all extensions' hooks, in the order given. No hook is a single slot.
5
+ *
6
+ * routes more HTTP routes (they may not replace the core's)
7
+ * admit the admit chain: whether a recipient here takes a message
8
+ * claimsFor claims this server makes about the author of a message sent here
9
+ * allowAddress who may take a new name on a hosted domain, or a lease
10
+ * verifyPrincipal pairing with a principal the core cannot verify itself (mailto:, tel:)
11
+ * upload whether an agent's upload is kept (a malware scan, a volume limit)
12
+ * admitsReader whether a published message's audience label admits a reader
13
+ * on events: stored, inbox, delivered, deliveryFailed, unroutable, recovered, signal
14
+ */
15
+ import type { AnyMessage, Claim, Hints, Json, Signal } from '@agnt-sdk/simon-protocol';
16
+ import type { Config } from './config.ts';
17
+ import type { Request, Route } from './http.ts';
18
+ import type { Agent, Store, StoredMessage } from './store/store.ts';
19
+ export type Log = (level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>) => void;
20
+ /** What extensions may use of the server. */
21
+ export interface ServerApi {
22
+ readonly config: Config;
23
+ readonly store: Store;
24
+ now(): Date;
25
+ log: Log;
26
+ /** The agent here that signed this request (spec section 8), or a 401 in the protocol's shape. */
27
+ authenticate(req: Request): Promise<Agent>;
28
+ /** Take a message an admit hook answered `later` into a recipient's inbox; false if it already was. */
29
+ admit(address: string, id: string): Promise<boolean>;
30
+ /** Offer a stored message to an agent here through the admit chain, as delivery does; true when it took or holds it. */
31
+ offer(address: string, id: string): Promise<boolean>;
32
+ /**
33
+ * Send a message written by an agent here (an application's own agents: notices, a bridge), checked and
34
+ * delivered exactly as `POST /simon/send` would; `hints` as its `simon-hints` header carries them.
35
+ */
36
+ send(message: AnyMessage, hints?: Hints): Promise<{
37
+ id: string;
38
+ receivedAt: string;
39
+ }>;
40
+ /** Retire an address (spec section 2.2): its keys revoked, its document gone, never registered again. */
41
+ retire(address: string): Promise<void>;
42
+ }
43
+ /** A message offered to one recipient here. */
44
+ export interface Arrival {
45
+ id: string;
46
+ message: AnyMessage;
47
+ recipient: string;
48
+ /** The origin of the server that carried it; this server's own for a message written here. */
49
+ carrier: string;
50
+ /**
51
+ * The claims agents here are shown (spec section 6.2): authoritative ones always, the rest only from a
52
+ * carrier this server trusts. Each acts-for and identifier claim carries `authoritative`.
53
+ */
54
+ claims: Claim[];
55
+ /** This recipient's hints from the envelope or from `simon-hints`. */
56
+ hints: Record<string, string>;
57
+ }
58
+ /**
59
+ * One link of the admit chain, read like Postfix's restrictions: `accept` takes the message and ends the
60
+ * chain, `refuse` drops it and ends the chain, `later` holds it (the extension calls `api.admit` when it
61
+ * decides) and ends the chain, and `undefined` has no opinion. When no link decides, the message is taken.
62
+ * The sender learns none of this: the answer was 202 either way.
63
+ */
64
+ export type Decision = 'accept' | 'refuse' | 'later' | undefined;
65
+ /** A registration of a new address, by the key that would hold it. */
66
+ export interface AddressRequest {
67
+ /**
68
+ * The address being registered: the one named, or the one leased. The core asks only about an address
69
+ * no agent holds and none ever retired, so an extension never decides, or sends a code, for one it
70
+ * cannot have.
71
+ */
72
+ address: string;
73
+ key: string;
74
+ /** The whole register body, for members of the extension's own, such as a code or a proof. */
75
+ body: Record<string, Json>;
76
+ }
77
+ /**
78
+ * Whether a key may take a new address on a hosted domain or the lease domain, whose names are this
79
+ * server's to give: `allow` or `refuse` ends the chain, `undefined` has no opinion. When no extension
80
+ * decides, the key takes it, first come, first served. A refusal is `409 address_taken`, the answer a held
81
+ * name gets; a thrown WireError is answered as itself. The hook only decides: it is asked on every call
82
+ * that would create the address, each round trip of a pairing included, and an `allow` may still end in
83
+ * no address (a pairing that fails, another key first), so it uses nothing up.
84
+ */
85
+ export type AddressDecision = 'allow' | 'refuse' | undefined;
86
+ /** A registration naming a principal of a kind the core does not verify. */
87
+ export interface PrincipalRequest extends AddressRequest {
88
+ principal: string;
89
+ }
90
+ /**
91
+ * `verified`: pair the agent with the principal. `pending`: answer 202 with `body` and wait for another
92
+ * call. `undefined`: not this extension's kind of principal. A refusal throws a WireError.
93
+ */
94
+ export type PrincipalAnswer = {
95
+ status: 'verified';
96
+ } | {
97
+ status: 'pending';
98
+ body: Record<string, Json>;
99
+ } | undefined;
100
+ /** Bytes an agent here uploaded, before they are kept. */
101
+ export interface Upload {
102
+ address: string;
103
+ sha256: string;
104
+ bytes: Buffer;
105
+ }
106
+ /**
107
+ * A reader asking for a published message whose `audience` header is `audience` (spec section 7.1). `reader`
108
+ * is the address that signed the request with a key of its own agent document, or null when nobody in
109
+ * particular asks.
110
+ */
111
+ export interface ReaderRequest {
112
+ message: AnyMessage;
113
+ audience: string;
114
+ reader: string | null;
115
+ }
116
+ /** `admit` or `refuse` ends the chain; `undefined` has no opinion. With no decision, only the author reads it. */
117
+ export type ReaderDecision = 'admit' | 'refuse' | undefined;
118
+ export interface Events {
119
+ /**
120
+ * A message was kept: sent by an agent here, or accepted from another server and taken or held by a
121
+ * recipient here. A message nobody here took is never kept, and never reported.
122
+ */
123
+ stored: {
124
+ message: StoredMessage;
125
+ };
126
+ /**
127
+ * A message entered an agent's inbox. Told at least once: the event is written with the entry and told
128
+ * afterwards, so a crash between the two repeats it rather than losing it. A listener dedupes by
129
+ * (address, message id) if it must.
130
+ */
131
+ inbox: {
132
+ address: string;
133
+ message: StoredMessage;
134
+ };
135
+ /** A server answered 202 for these recipients (this server, for its own agents). */
136
+ delivered: {
137
+ id: string;
138
+ server: string;
139
+ recipients: string[];
140
+ };
141
+ /** Delivery to these recipients ended without a 202; `reason` is a resolution outcome or an error code. */
142
+ deliveryFailed: {
143
+ id: string;
144
+ server: string | null;
145
+ recipients: string[];
146
+ reason: string;
147
+ };
148
+ /**
149
+ * These recipients' domain names no SIMON server (`not_found`). A bridge may reach them another way: a
150
+ * listener that answers `true` took them, and their delivery counts as delivered rather than failed.
151
+ */
152
+ unroutable: {
153
+ id: string;
154
+ domain: string;
155
+ recipients: string[];
156
+ message: AnyMessage;
157
+ };
158
+ /**
159
+ * An agent's keys were replaced by `key`: `by` its address's mailbox, a code sent there, so whoever holds
160
+ * the mailbox now holds the agent; or `by` its key principal, the agent's root of trust.
161
+ */
162
+ recovered: {
163
+ address: string;
164
+ key: string;
165
+ by: 'mailbox' | 'principal';
166
+ };
167
+ /**
168
+ * A signal passed for an agent here, about a message it holds, from a party to that message (spec section
169
+ * 6.4; this server's policy). Nothing keeps it: a listener shows it now or never.
170
+ */
171
+ signal: {
172
+ recipient: string;
173
+ signal: Signal;
174
+ };
175
+ }
176
+ /** What a listener answers: only an `unroutable` listener may answer, `true` when it took the recipients. */
177
+ type Answer<E extends keyof Events> = E extends 'unroutable' ? boolean | void : void;
178
+ export type Listeners = {
179
+ [E in keyof Events]?: (event: Events[E], api: ServerApi) => Answer<E> | Promise<Answer<E>>;
180
+ };
181
+ export interface Extension {
182
+ name: string;
183
+ routes?: Route<ServerApi>[];
184
+ admit?(arrival: Arrival, api: ServerApi): Decision | Promise<Decision>;
185
+ claimsFor?(message: AnyMessage, agent: Agent, api: ServerApi): Claim[] | Promise<Claim[]>;
186
+ allowAddress?(request: AddressRequest, api: ServerApi): AddressDecision | Promise<AddressDecision>;
187
+ verifyPrincipal?(request: PrincipalRequest, api: ServerApi): PrincipalAnswer | Promise<PrincipalAnswer>;
188
+ /** Refuse an upload by throwing a WireError; return to let it be kept. */
189
+ upload?(upload: Upload, api: ServerApi): void | Promise<void>;
190
+ admitsReader?(request: ReaderRequest, api: ServerApi): ReaderDecision | Promise<ReaderDecision>;
191
+ on?: Listeners;
192
+ }
193
+ /** All extensions' hooks, run as one. */
194
+ export declare class Hooks {
195
+ #private;
196
+ constructor(extensions: Extension[], api: () => ServerApi);
197
+ get routes(): Route<ServerApi>[];
198
+ /** The first decision in the chain, or `accept` when none decides. */
199
+ admit(arrival: Arrival): Promise<'accept' | 'refuse' | 'later'>;
200
+ claimsFor(message: AnyMessage, agent: Agent): Promise<Claim[]>;
201
+ /** The first decision on a new hosted or leased address, or `allow` when none decides. */
202
+ allowAddress(request: AddressRequest): Promise<'allow' | 'refuse'>;
203
+ /** The first extension's answer for this principal, or undefined when none verifies its kind. */
204
+ verifyPrincipal(request: PrincipalRequest): Promise<PrincipalAnswer>;
205
+ /** The first decision on a reader of a message with an audience, or `refuse` when none decides. */
206
+ admitsReader(request: ReaderRequest): Promise<'admit' | 'refuse'>;
207
+ /** Every extension's check of an upload, in order; the first refusal throws. */
208
+ upload(upload: Upload): Promise<void>;
209
+ /**
210
+ * Tell every listener, in order. A listener that fails is logged; it never fails the core's work. For
211
+ * `unroutable`, the first listener that answers `true` takes the recipients, and the rest are not told.
212
+ * Answers whether one did.
213
+ */
214
+ emit<E extends keyof Events>(event: E, payload: Events[E]): Promise<boolean>;
215
+ }
216
+ export {};
@@ -0,0 +1,75 @@
1
+ /** All extensions' hooks, run as one. */
2
+ export class Hooks {
3
+ #extensions;
4
+ #api;
5
+ constructor(extensions, api) {
6
+ this.#extensions = extensions;
7
+ this.#api = api;
8
+ }
9
+ get routes() {
10
+ return this.#extensions.flatMap(e => e.routes ?? []);
11
+ }
12
+ /** The first decision in the chain, or `accept` when none decides. */
13
+ async admit(arrival) {
14
+ for (const e of this.#extensions) {
15
+ const decision = await e.admit?.(arrival, this.#api());
16
+ if (decision !== undefined)
17
+ return decision;
18
+ }
19
+ return 'accept';
20
+ }
21
+ async claimsFor(message, agent) {
22
+ const all = await Promise.all(this.#extensions.map(e => e.claimsFor?.(message, agent, this.#api()) ?? []));
23
+ return all.flat();
24
+ }
25
+ /** The first decision on a new hosted or leased address, or `allow` when none decides. */
26
+ async allowAddress(request) {
27
+ for (const e of this.#extensions) {
28
+ const decision = await e.allowAddress?.(request, this.#api());
29
+ if (decision !== undefined)
30
+ return decision;
31
+ }
32
+ return 'allow';
33
+ }
34
+ /** The first extension's answer for this principal, or undefined when none verifies its kind. */
35
+ async verifyPrincipal(request) {
36
+ for (const e of this.#extensions) {
37
+ const answer = await e.verifyPrincipal?.(request, this.#api());
38
+ if (answer !== undefined)
39
+ return answer;
40
+ }
41
+ return undefined;
42
+ }
43
+ /** The first decision on a reader of a message with an audience, or `refuse` when none decides. */
44
+ async admitsReader(request) {
45
+ for (const e of this.#extensions) {
46
+ const decision = await e.admitsReader?.(request, this.#api());
47
+ if (decision !== undefined)
48
+ return decision;
49
+ }
50
+ return 'refuse';
51
+ }
52
+ /** Every extension's check of an upload, in order; the first refusal throws. */
53
+ async upload(upload) {
54
+ for (const e of this.#extensions)
55
+ await e.upload?.(upload, this.#api());
56
+ }
57
+ /**
58
+ * Tell every listener, in order. A listener that fails is logged; it never fails the core's work. For
59
+ * `unroutable`, the first listener that answers `true` takes the recipients, and the rest are not told.
60
+ * Answers whether one did.
61
+ */
62
+ async emit(event, payload) {
63
+ for (const e of this.#extensions) {
64
+ const listener = e.on?.[event];
65
+ try {
66
+ if ((await listener?.(payload, this.#api())) === true && event === 'unroutable')
67
+ return true;
68
+ }
69
+ catch (err) {
70
+ this.#api().log('error', `extension ${e.name} failed on ${event}`, { error: String(err) });
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Fan-out: offering a stored message to its recipients here, after the answer was given, so no answer and
3
+ * no timing depends on who is here or what they decide (spec section 6.3, "Policy is silent").
4
+ *
5
+ * - **Arrivals.** A message accepted for recipients here waits as one arrival per recipient. Each is
6
+ * offered on its own: an arrival whose offer fails (an extension that throws, a store error) is tried
7
+ * again later, with backoff, and holds up no other.
8
+ * - **The admit chain** decides for each recipient. A message from another server that no recipient here
9
+ * took or holds is not kept.
10
+ * - **Inbox events** are written with the inbox entry and told afterwards, at least once: a crash between
11
+ * the two repeats the event, never loses it.
12
+ */
13
+ import type { Claim, Hints } from '@agnt-sdk/simon-protocol';
14
+ import { type Context } from './context.ts';
15
+ import type { QueuedArrival, StoredMessage } from './store/store.ts';
16
+ /** Whether this server shows its agents a carrier's claims that are not authoritative: its own, or one trusted. */
17
+ export declare const trustsClaimsOf: (ctx: Context, carrier: string) => boolean;
18
+ /**
19
+ * The claims an agent here is shown with a message (CLIENT.md sync): the authoritative ones always, and
20
+ * the others only from a carrier this server trusts. Each acts-for and identifier claim carries `authoritative`.
21
+ */
22
+ export declare const shownClaims: (ctx: Context, carrier: string, claims: Claim[]) => Claim[];
23
+ /** One arrival per recipient, each with its own hints, due now. */
24
+ export declare const arrivalsFor: (id: string, recipients: string[], hints: Hints, now: Date) => QueuedArrival[];
25
+ /** Offer due arrivals, each on its own. Returns how many were taken up. */
26
+ export declare function processArrivals(ctx: Context): Promise<number>;
27
+ /** Offer a stored message to those of `recipients` who are agents here. True when any took it or holds it. */
28
+ export declare function fanOut(ctx: Context, record: StoredMessage, recipients: string[], hints: Hints): Promise<boolean>;
29
+ /** Put a message into an agent's inbox once; its inbox event is told by `tellInboxEvents`. */
30
+ export declare function takeIntoInbox(ctx: Context, address: string, record: StoredMessage, generation?: string): Promise<boolean>;
31
+ /** Tell the extensions of new inbox entries, at least once each. Returns how many were told. */
32
+ export declare function tellInboxEvents(ctx: Context): Promise<number>;
package/dist/fanout.js ADDED
@@ -0,0 +1,79 @@
1
+ import { iso, later } from "./context.js";
2
+ import { changed } from "./sync.js";
3
+ const BATCH = 50;
4
+ const MIN_BACKOFF_MS = 60_000;
5
+ const MAX_BACKOFF_MS = 3600_000;
6
+ /** Whether this server shows its agents a carrier's claims that are not authoritative: its own, or one trusted. */
7
+ export const trustsClaimsOf = (ctx, carrier) => carrier === ctx.config.origin || ctx.config.trustedClaimServers.includes(carrier);
8
+ /**
9
+ * The claims an agent here is shown with a message (CLIENT.md sync): the authoritative ones always, and
10
+ * the others only from a carrier this server trusts. Each acts-for and identifier claim carries `authoritative`.
11
+ */
12
+ export const shownClaims = (ctx, carrier, claims) => trustsClaimsOf(ctx, carrier) ? claims : claims.filter(c => c.authoritative === true);
13
+ /** One arrival per recipient, each with its own hints, due now. */
14
+ export const arrivalsFor = (id, recipients, hints, now) => recipients.map(recipient => ({ id, recipient, hints: hints[recipient] ?? {}, attempts: 0, dueAt: iso(now) }));
15
+ /** Offer due arrivals, each on its own. Returns how many were taken up. */
16
+ export async function processArrivals(ctx) {
17
+ const due = await ctx.store.arrivals(iso(ctx.now()), BATCH);
18
+ for (const { arrival, message: record, generation } of due) {
19
+ try {
20
+ const kept = await offer(ctx, record, arrival.recipient, arrival.hints, generation);
21
+ if ((await ctx.store.arrivalDone(record.id, arrival.recipient, kept, generation)) === 'kept') {
22
+ await ctx.hooks.emit('stored', { message: record });
23
+ }
24
+ }
25
+ catch (e) {
26
+ const delay = Math.min(MIN_BACKOFF_MS * 2 ** arrival.attempts, MAX_BACKOFF_MS);
27
+ ctx.log('error', 'offering an arrival failed; retrying', {
28
+ id: record.id,
29
+ recipient: arrival.recipient,
30
+ error: String(e),
31
+ });
32
+ await ctx.store.arrivalRetry(record.id, arrival.recipient, later(ctx.now(), delay), generation);
33
+ }
34
+ }
35
+ return due.length;
36
+ }
37
+ /** Offer a stored message to those of `recipients` who are agents here. True when any took it or holds it. */
38
+ export async function fanOut(ctx, record, recipients, hints) {
39
+ let kept = false;
40
+ for (const address of recipients)
41
+ if (await offer(ctx, record, address, hints[address] ?? {}))
42
+ kept = true;
43
+ return kept;
44
+ }
45
+ /** Offer a message to one recipient here through the admit chain: true when it took or holds it. */
46
+ async function offer(ctx, record, address, hints, generation) {
47
+ const agent = await ctx.store.agentByAddress(address);
48
+ if (!agent || agent.retiredAt)
49
+ return false;
50
+ const decision = await ctx.hooks.admit({
51
+ id: record.id,
52
+ message: record.message,
53
+ recipient: address,
54
+ carrier: record.carrier,
55
+ claims: shownClaims(ctx, record.carrier, record.claims),
56
+ hints,
57
+ });
58
+ if (decision === 'accept')
59
+ await takeIntoInbox(ctx, address, record, generation);
60
+ return decision !== 'refuse';
61
+ }
62
+ /** Put a message into an agent's inbox once; its inbox event is told by `tellInboxEvents`. */
63
+ export async function takeIntoInbox(ctx, address, record, generation) {
64
+ const added = await ctx.store.addToInbox(address, record.id, iso(ctx.now()), generation, later(ctx.now(), ctx.config.syncDays * 86_400_000));
65
+ if (added)
66
+ await changed(ctx, address);
67
+ return added;
68
+ }
69
+ /** Tell the extensions of new inbox entries, at least once each. Returns how many were told. */
70
+ export async function tellInboxEvents(ctx) {
71
+ const entries = await ctx.store.inboxEvents(BATCH);
72
+ for (const e of entries) {
73
+ const message = await ctx.store.message(e.id);
74
+ if (message)
75
+ await ctx.hooks.emit('inbox', { address: e.address, message });
76
+ await ctx.store.inboxEventDone(e.cursor);
77
+ }
78
+ return entries.length;
79
+ }