@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,38 @@
1
+ import type { Reply, Request } from './http.ts';
2
+ import { type Dns } from './net/dns.ts';
3
+ import { type Http, type HttpRequest, type HttpResponse } from './net/fetch.ts';
4
+ export declare class Clock {
5
+ t: number;
6
+ now: () => Date;
7
+ advance(ms: number): void;
8
+ }
9
+ /** TXT records by name; a name in `failing` answers SERVFAIL. */
10
+ export declare class FakeDns implements Dns {
11
+ readonly records: Map<string, string[][]>;
12
+ readonly failing: Set<string>;
13
+ txt(name: string): Promise<string[][]>;
14
+ addresses(): Promise<string[]>;
15
+ point(domain: string, origin: string): void;
16
+ }
17
+ type Host = ((req: Request) => Promise<Reply>) | 'down';
18
+ /** Every deadline on the fake network, at most: a tarpit costs a test this long. */
19
+ export declare const TARPIT_MS = 150;
20
+ /**
21
+ * Origins answered in-process. A request reaches the host's handler as an HTTP request would. Each host has
22
+ * one address, its own name unless `addresses` maps it to another, so several names can share one machine.
23
+ */
24
+ export declare class FakeNet implements Http {
25
+ readonly hosts: Map<string, Host>;
26
+ readonly addresses: Map<string, string>;
27
+ /** The client address every request appears to come from. */
28
+ ip: string;
29
+ readonly sent: {
30
+ method: string;
31
+ url: string;
32
+ }[];
33
+ request(r: HttpRequest): Promise<HttpResponse>;
34
+ }
35
+ export declare function toRequest(method: string, url: URL, headers: Record<string, string>, body: Buffer, ip: string): Request;
36
+ /** A reply's body as the node:http adapter would send it. */
37
+ export declare const replyBody: (reply: Reply) => Buffer<ArrayBufferLike>;
38
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A fake outside world, for tests of the server and of applications built on it: one movable clock, DNS
3
+ * answered from a map, and HTTP answered in-process by the origins registered on it.
4
+ *
5
+ * const clock = new Clock();
6
+ * const dns = new FakeDns();
7
+ * const net = new FakeNet();
8
+ * const server = createServer({ config, store, dns, http: net, now: clock.now, worker: false });
9
+ * net.hosts.set(config.origin, req => server.handle(req));
10
+ * dns.point('example.test', config.origin);
11
+ */
12
+ import { canonicalize } from '@agnt-sdk/simon-protocol';
13
+ import { DnsFailure } from "./net/dns.js";
14
+ import { Unreachable } from "./net/fetch.js";
15
+ export class Clock {
16
+ t = Date.parse('2026-10-15T12:00:00.000Z');
17
+ now = () => new Date(this.t);
18
+ advance(ms) {
19
+ this.t += ms;
20
+ }
21
+ }
22
+ /** TXT records by name; a name in `failing` answers SERVFAIL. */
23
+ export class FakeDns {
24
+ records = new Map();
25
+ failing = new Set();
26
+ async txt(name) {
27
+ if (this.failing.has(name))
28
+ throw new DnsFailure(`${name}: SERVFAIL`);
29
+ return this.records.get(name) ?? [];
30
+ }
31
+ async addresses() {
32
+ return ['203.0.113.10'];
33
+ }
34
+ point(domain, origin) {
35
+ this.records.set(`_simon.${domain}`, [[`v=simon1; server=${origin}`]]);
36
+ }
37
+ }
38
+ /** Every deadline on the fake network, at most: a tarpit costs a test this long. */
39
+ export const TARPIT_MS = 150;
40
+ /**
41
+ * Origins answered in-process. A request reaches the host's handler as an HTTP request would. Each host has
42
+ * one address, its own name unless `addresses` maps it to another, so several names can share one machine.
43
+ */
44
+ export class FakeNet {
45
+ hosts = new Map();
46
+ addresses = new Map();
47
+ /** The client address every request appears to come from. */
48
+ ip = '198.51.100.7';
49
+ sent = [];
50
+ async request(r) {
51
+ const url = new URL(r.url);
52
+ this.sent.push({ method: r.method, url: r.url });
53
+ const host = this.hosts.get(url.origin);
54
+ if (!host)
55
+ throw new Unreachable('absent', `no such host: ${url.host}`);
56
+ const address = this.addresses.get(url.hostname) ?? url.hostname;
57
+ if (r.avoid?.(address))
58
+ throw new Unreachable('refused', `${address} is avoided`, address);
59
+ if (host === 'down')
60
+ throw new Unreachable('refused', `${url.host} refuses connections`, address);
61
+ // Deadlines are honoured, scaled down so a test never waits long for one.
62
+ let timer;
63
+ const deadline = new Promise((_, reject) => {
64
+ timer = setTimeout(() => reject(new Unreachable('timeout', `${url.host}: no answer in time`, address)), Math.min(r.timeoutMs ?? TARPIT_MS, TARPIT_MS));
65
+ });
66
+ const request = toRequest(r.method, url, r.headers ?? {}, Buffer.from(r.body ?? new Uint8Array()), this.ip);
67
+ const reply = await Promise.race([host(request), deadline]).finally(() => clearTimeout(timer));
68
+ const body = replyBody(reply);
69
+ if (body.length > r.maxBytes)
70
+ throw new Unreachable('too_large', 'answer too large');
71
+ return { status: reply.status, headers: { ...reply.headers }, body };
72
+ }
73
+ }
74
+ export function toRequest(method, url, headers, body, ip) {
75
+ return {
76
+ method,
77
+ target: url.pathname + url.search,
78
+ path: url.pathname,
79
+ query: url.searchParams,
80
+ params: {},
81
+ headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
82
+ body,
83
+ ip,
84
+ };
85
+ }
86
+ /** A reply's body as the node:http adapter would send it. */
87
+ export const replyBody = (reply) => reply.bytes ?? (reply.json === undefined ? Buffer.alloc(0) : Buffer.from(canonicalize(reply.json)));
@@ -0,0 +1,10 @@
1
+ import { type Context } from './context.ts';
2
+ import { type Route } from './http.ts';
3
+ export declare function webhookRoutes(ctx: Context): Route[];
4
+ /** Each delivery is leased, so a late attempt cannot settle or disable a successor's webhook. */
5
+ export declare class WebhookPoster {
6
+ #private;
7
+ constructor(ctx: Context);
8
+ tick(): Promise<number>;
9
+ idle(): Promise<void>;
10
+ }
@@ -0,0 +1,162 @@
1
+ /** Verified, signed wakeups for clients that do not hold a sync connection open. */
2
+ import crypto from 'node:crypto';
3
+ import { canonicalBytes, invalid, signRequest } from '@agnt-sdk/simon-protocol';
4
+ import { readBody } from "./account.js";
5
+ import { authenticateAgent } from "./auth.js";
6
+ import { iso, later } from "./context.js";
7
+ import { conflict, notFound } from "./errors.js";
8
+ import { json } from "./http.js";
9
+ const hash = (s) => crypto.createHash('sha256').update(s).digest('hex');
10
+ const matches = (code, expected) => {
11
+ const actual = Buffer.from(hash(code), 'hex'), wanted = Buffer.from(expected, 'hex');
12
+ return actual.length === wanted.length && crypto.timingSafeEqual(actual, wanted);
13
+ };
14
+ const LIMIT = 5, CONCURRENCY = 4, TIMEOUT_MS = 10_000, LEASE_MS = 30_000;
15
+ const FAILURE_MS = 3 * 86_400_000;
16
+ async function post(ctx, url, payload) {
17
+ const body = canonicalBytes(payload);
18
+ const headers = signRequest(ctx.config.serverKey, {
19
+ method: 'POST',
20
+ url,
21
+ body,
22
+ now: ctx.now().getTime(),
23
+ server: ctx.config.origin,
24
+ });
25
+ const res = await ctx.http.request({
26
+ method: 'POST',
27
+ url,
28
+ body,
29
+ headers: { ...headers, 'content-type': 'application/json' },
30
+ maxBytes: 16_384,
31
+ timeoutMs: TIMEOUT_MS,
32
+ });
33
+ return res.status >= 200 && res.status < 300;
34
+ }
35
+ export function webhookRoutes(ctx) {
36
+ const owned = async (id, address) => {
37
+ const row = await ctx.store.webhook(id);
38
+ if (!row || row.address !== address)
39
+ throw notFound();
40
+ return row;
41
+ };
42
+ return [
43
+ {
44
+ method: 'POST',
45
+ path: '/simon/webhooks',
46
+ async handle(req) {
47
+ const agent = await authenticateAgent(ctx, req);
48
+ ctx.rate.hit('send', agent.address);
49
+ const body = readBody(req.body, ['url']);
50
+ let url;
51
+ try {
52
+ url = new URL(body.url ?? '');
53
+ }
54
+ catch {
55
+ throw invalid('url must be an HTTPS URL.', { field: 'url' });
56
+ }
57
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.href.length > 2000) {
58
+ throw invalid('url must be an HTTPS URL without credentials or a fragment, at most 2000 characters.', {
59
+ field: 'url',
60
+ });
61
+ }
62
+ const code = crypto.randomBytes(32).toString('base64url');
63
+ const row = {
64
+ id: crypto.randomUUID(),
65
+ address: agent.address,
66
+ url: url.href,
67
+ codeHash: hash(code),
68
+ verified: false,
69
+ createdAt: iso(ctx.now()),
70
+ failingSince: null,
71
+ };
72
+ if (!(await ctx.store.addWebhook(row, LIMIT)))
73
+ throw conflict('too_many_webhooks', `An agent holds at most ${LIMIT} webhooks.`);
74
+ // A failed verification post leaves an unverified row the owner may delete and recreate. It sends
75
+ // no wakeups, and never deletes a row that the receiver verified while the HTTP answer was delayed.
76
+ ctx.defer(async () => {
77
+ await post(ctx, row.url, { type: 'verify', code });
78
+ });
79
+ return json({ id: row.id }, 201);
80
+ },
81
+ },
82
+ {
83
+ method: 'POST',
84
+ path: '/simon/webhooks/:id/verify',
85
+ async handle(req) {
86
+ const agent = await authenticateAgent(ctx, req);
87
+ const row = await owned(req.params.id, agent.address);
88
+ const { code } = readBody(req.body, ['code']);
89
+ if (typeof code !== 'string' || !matches(code, row.codeHash))
90
+ throw invalid('The verification code does not match.', { field: 'code' });
91
+ await ctx.store.verifyWebhook(row.id);
92
+ await ctx.store.wakeWebhooks(agent.address, iso(ctx.now()));
93
+ ctx.wake();
94
+ return json({ id: row.id, verified: true });
95
+ },
96
+ },
97
+ {
98
+ method: 'DELETE',
99
+ path: '/simon/webhooks/:id',
100
+ async handle(req) {
101
+ const agent = await authenticateAgent(ctx, req);
102
+ const row = await owned(req.params.id, agent.address);
103
+ await ctx.store.deleteWebhook(row.id);
104
+ return json({ deleted: true });
105
+ },
106
+ },
107
+ ];
108
+ }
109
+ /** Each delivery is leased, so a late attempt cannot settle or disable a successor's webhook. */
110
+ export class WebhookPoster {
111
+ #ctx;
112
+ #running = new Set();
113
+ #active = 0;
114
+ constructor(ctx) {
115
+ this.#ctx = ctx;
116
+ }
117
+ async tick() {
118
+ const room = CONCURRENCY - this.#active;
119
+ if (room <= 0)
120
+ return 0;
121
+ const ctx = this.#ctx, now = ctx.now(), token = crypto.randomUUID();
122
+ const rows = await ctx.store.claimWebhooks(iso(now), room, later(now, 1000), {
123
+ token,
124
+ until: later(now, LEASE_MS),
125
+ });
126
+ this.#active += rows.length;
127
+ for (const row of rows) {
128
+ const task = this.#deliver(row, token)
129
+ .catch(error => ctx.log('error', 'webhook attempt failed', { id: row.id, error: String(error) }))
130
+ .finally(() => {
131
+ this.#active--;
132
+ this.#running.delete(task);
133
+ });
134
+ this.#running.add(task);
135
+ }
136
+ return rows.length;
137
+ }
138
+ async #deliver(row, token) {
139
+ const ctx = this.#ctx;
140
+ const current = await ctx.store.webhook(row.id);
141
+ const agent = await ctx.store.agentByAddress(row.address);
142
+ if (!current || !current.verified || current.url !== row.url || !agent || agent.retiredAt) {
143
+ await ctx.store.settleWebhook(row.id, row.failingSince, null, token, true);
144
+ return;
145
+ }
146
+ let success = false;
147
+ try {
148
+ success = await post(ctx, row.url, { type: 'changed', agent: row.address });
149
+ }
150
+ catch {
151
+ /* Retry below. */
152
+ }
153
+ const now = ctx.now();
154
+ const failingSince = success ? null : (row.failingSince ?? iso(now));
155
+ const disable = failingSince !== null && now.getTime() - Date.parse(failingSince) >= FAILURE_MS;
156
+ await ctx.store.settleWebhook(row.id, failingSince, success || disable ? null : later(now, 60_000), token, disable);
157
+ }
158
+ async idle() {
159
+ while (this.#running.size)
160
+ await Promise.all(this.#running);
161
+ }
162
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The worker loop: what happens after answers are given. Every second, and at once when woken, it runs a
3
+ * pass (arrivals offered, inbox events told, part fetches and deliveries started); it sweeps retention
4
+ * every ten minutes. It runs in the server's process by default.
5
+ */
6
+ export interface Worker {
7
+ /** Run a pass soon. */
8
+ wake(): void;
9
+ /** Stop the loop and wait for the pass underway. */
10
+ stop(): Promise<void>;
11
+ }
12
+ export declare function startWorker(pass: () => Promise<void>, sweep: () => Promise<void>, log: (error: unknown) => void, opts?: {
13
+ intervalMs?: number;
14
+ sweepMs?: number;
15
+ }): Worker;
package/dist/worker.js ADDED
@@ -0,0 +1,33 @@
1
+ export function startWorker(pass, sweep, log, opts = {}) {
2
+ let running = null;
3
+ let again = false;
4
+ let stopped = false;
5
+ const run = () => {
6
+ if (stopped)
7
+ return;
8
+ if (running)
9
+ return void (again = true);
10
+ running = pass()
11
+ .catch(log)
12
+ .finally(() => {
13
+ running = null;
14
+ if (again) {
15
+ again = false;
16
+ run();
17
+ }
18
+ });
19
+ };
20
+ const interval = setInterval(run, opts.intervalMs ?? 1000);
21
+ const sweeper = setInterval(() => void sweep().catch(log), opts.sweepMs ?? 10 * 60_000);
22
+ interval.unref();
23
+ sweeper.unref();
24
+ return {
25
+ wake: () => setImmediate(run),
26
+ async stop() {
27
+ stopped = true;
28
+ clearInterval(interval);
29
+ clearInterval(sweeper);
30
+ await running;
31
+ },
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@agnt-sdk/simon-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The SIMON reference server: the whole protocol and client API, hardened, with Node's standard library only, and the Store contract any storage proves itself with.",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "simon",
8
+ "agents",
9
+ "ai-agents",
10
+ "protocol",
11
+ "signed-messages",
12
+ "server",
13
+ "federation"
14
+ ],
15
+ "homepage": "https://github.com/agnt-ai/simon-protocol/tree/main/server#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/agnt-ai/simon-protocol/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/agnt-ai/simon-protocol.git",
22
+ "directory": "server"
23
+ },
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./contract": {
31
+ "types": "./dist/store/contract.d.ts",
32
+ "default": "./dist/store/contract.js"
33
+ },
34
+ "./testing": {
35
+ "types": "./dist/testing.d.ts",
36
+ "default": "./dist/testing.js"
37
+ }
38
+ },
39
+ "bin": {
40
+ "simon-server": "./dist/main.js"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "LICENSE",
46
+ "NOTICE"
47
+ ],
48
+ "engines": {
49
+ "node": ">=24.6.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "provenance": true
54
+ },
55
+ "dependencies": {
56
+ "@agnt-sdk/simon-protocol": "0.1.0"
57
+ },
58
+ "gitHead": "0047e948e09b664b52ab2ef690a537e0f7d9ba76"
59
+ }