holon-sh 0.0.1-dev.7 → 0.0.1-dev.9

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 (52) hide show
  1. package/README.md +96 -67
  2. package/dist/context.d.ts +55 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +9 -0
  5. package/dist/context.js.map +1 -0
  6. package/dist/ext/events.d.ts +268 -0
  7. package/dist/ext/events.d.ts.map +1 -0
  8. package/dist/ext/events.js +619 -0
  9. package/dist/ext/events.js.map +1 -0
  10. package/dist/ext/files.client.d.ts +50 -0
  11. package/dist/ext/files.client.d.ts.map +1 -0
  12. package/dist/ext/files.client.js +359 -0
  13. package/dist/ext/files.client.js.map +1 -0
  14. package/dist/ext/files.d.ts +21 -0
  15. package/dist/ext/files.d.ts.map +1 -0
  16. package/dist/ext/files.js +3 -0
  17. package/dist/ext/files.js.map +1 -0
  18. package/dist/ext/platform.browser.d.ts +4 -0
  19. package/dist/ext/platform.browser.d.ts.map +1 -0
  20. package/dist/ext/platform.browser.js +15 -0
  21. package/dist/ext/platform.browser.js.map +1 -0
  22. package/dist/ext/platform.node.d.ts +4 -0
  23. package/dist/ext/platform.node.d.ts.map +1 -0
  24. package/dist/ext/platform.node.js +45 -0
  25. package/dist/ext/platform.node.js.map +1 -0
  26. package/dist/ext/webhooks.d.ts +18 -0
  27. package/dist/ext/webhooks.d.ts.map +1 -0
  28. package/dist/ext/webhooks.js +527 -0
  29. package/dist/ext/webhooks.js.map +1 -0
  30. package/dist/protocol.d.ts +134 -0
  31. package/dist/protocol.d.ts.map +1 -0
  32. package/dist/protocol.js +32 -0
  33. package/dist/protocol.js.map +1 -0
  34. package/dist/runtime.d.ts +72 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +453 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/package.json +56 -19
  39. package/{context.d.ts → src/context.ts} +11 -1
  40. package/src/ext/events.ts +812 -0
  41. package/src/ext/files.client.ts +329 -0
  42. package/src/ext/files.ts +16 -0
  43. package/src/ext/platform.browser.ts +15 -0
  44. package/src/ext/platform.node.ts +43 -0
  45. package/src/ext/webhooks.ts +512 -0
  46. package/src/protocol.ts +119 -0
  47. package/src/runtime.ts +467 -0
  48. package/context.mjs +0 -6
  49. package/protocol.d.ts +0 -51
  50. package/runtime.d.ts +0 -47
  51. package/runtime.mjs +0 -207
  52. package/validate.mjs +0 -12
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lookup } from "node:dns";
3
+ import { isIP } from "node:net";
4
+ export async function base64(blob) {
5
+ return Buffer.from(await blob.arrayBuffer()).toString("base64");
6
+ }
7
+ export async function sha256(blob) {
8
+ const hash = createHash("sha256");
9
+ for await (const chunk of blob.stream())
10
+ hash.update(chunk);
11
+ return hash.digest("base64url");
12
+ }
13
+ let loaded;
14
+ export async function fetchGlobal(url, init) {
15
+ const { fetch, agent, global } = await (loaded ??= load());
16
+ const host = new URL(url).hostname.replace(/^\[(.*)\]$/, "$1");
17
+ if (isIP(host) && !global(host))
18
+ throw new TypeError("fetch failed", { cause: new Error("the address is not globally reachable") });
19
+ return fetch(url, { ...init, dispatcher: agent });
20
+ }
21
+ async function load() {
22
+ const [{ Agent, fetch }, { default: ipaddr }] = await Promise.all([import("undici"), import("ipaddr.js")]);
23
+ const nat64 = ipaddr.parseCIDR("64:ff9b::/96");
24
+ const global = (address) => {
25
+ let ip = ipaddr.process(address);
26
+ if (ip.kind() === "ipv6" && ip.match(nat64))
27
+ ip = new ipaddr.IPv4(ip.toByteArray().slice(12));
28
+ return ip.range() === "unicast";
29
+ };
30
+ const resolve = (hostname, options, callback) => lookup(hostname, { ...options, all: true }, (error, addresses) => {
31
+ if (error)
32
+ return callback(error, "");
33
+ const usable = addresses.filter(({ address }) => global(address));
34
+ const [first] = usable;
35
+ if (!first)
36
+ return callback(Object.assign(new Error("the host has no globally reachable address"), { code: "ENOTFOUND" }), "");
37
+ if (options.all)
38
+ callback(null, usable);
39
+ else
40
+ callback(null, first.address, first.family);
41
+ });
42
+ const agent = new Agent({ connect: { lookup: resolve } });
43
+ return { fetch, agent, global };
44
+ }
45
+ //# sourceMappingURL=platform.node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.node.js","sourceRoot":"","sources":["../../src/ext/platform.node.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { DeliveryMode } from "./events.js";
2
+ /** How webhook delivery runs: createEvents' `webhook` option. Its `webhookTtl` turns it on and caps grants. */
3
+ export interface WebhookOptions {
4
+ /** For tests only: accepts http callbacks and callbacks at private addresses, such as a receiver on localhost. */
5
+ allowInsecureLocalCallbacksForTesting?: boolean;
6
+ }
7
+ /**
8
+ * The webhook delivery mode, which createEvents adds when given a webhookTtl, the `ttl` here. Access is checked again
9
+ * every `heartbeat`, as push streams check it. It serves
10
+ * events/subscribe and events/unsubscribe to authenticated callers, verifies each callback by a challenge before
11
+ * delivering to it, signs each delivery per Standard Webhooks with the client's secret, retries failed ones, and lets
12
+ * subscriptions lapse when not refreshed.
13
+ */
14
+ export declare function webhooks({ ttl, heartbeat, allowInsecureLocalCallbacksForTesting }: WebhookOptions & {
15
+ ttl: number;
16
+ heartbeat: number;
17
+ }): DeliveryMode;
18
+ //# sourceMappingURL=webhooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../../src/ext/webhooks.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAqF,MAAM,aAAa,CAAC;AAiBnI,+GAA+G;AAC/G,MAAM,WAAW,cAAc;IAC7B,kHAAkH;IAClH,qCAAqC,CAAC,EAAE,OAAO,CAAC;CACjD;AAuID;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,qCAA6C,EAAE,EAAE,cAAc,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,YAAY,CAkV7J"}
@@ -0,0 +1,527 @@
1
+ // Webhook delivery of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
2
+ // https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md#webhook-based-delivery
3
+ // This is the server side: the webhook delivery mode createEvents adds when given a webhookTtl. Deliveries are signed by
4
+ // the Standard Webhooks library. Nothing here needs a Node module until it sends: a server is evaluated for its
5
+ // contract where node:crypto and its neighbours are not there. The client side, a receiver, waits for a Holon app to be
6
+ // able to serve one.
7
+ import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
8
+ import { Webhook } from "standardwebhooks";
9
+ import { getContext } from "../context.js";
10
+ const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
11
+ const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
12
+ // The sketch's general-purpose codes, provisional until the specification allocates them.
13
+ const NOT_FOUND = -32023;
14
+ const FORBIDDEN = -32024;
15
+ const RESOURCE_EXHAUSTED = -32025;
16
+ const CALLBACK_ENDPOINT_ERROR = -32027;
17
+ // A body over this is refused by many receivers, so the sketch asks servers to stay under it.
18
+ const MAX_BODY = 256 * 1024;
19
+ // Old and new secrets both sign deliveries this long after a refresh changes the secret, so in-flight ones verify.
20
+ const ROTATION_GRACE = 5 * 60_000;
21
+ // The shortest lifetime granted, unless the cap is shorter, so clients do not refresh too often.
22
+ const MIN_TTL = 60_000;
23
+ // Each event is sent this many times at most, the first retry after BACKOFF and each later one five times longer:
24
+ // about thirteen minutes in all, within the sketch's suggestion.
25
+ const ATTEMPTS = 5;
26
+ const BACKOFF = 5000;
27
+ // How long a delivery has, from connecting to its answer, as the sketch suggests.
28
+ const TIMEOUT = 5000;
29
+ // How many events may be in flight to one subscription, beyond which the client is told of a gap.
30
+ const MAX_PENDING = 1000;
31
+ // How many deliveries run to one endpoint at once.
32
+ const CONCURRENCY = 4;
33
+ // A URL whose challenge failed is not challenged again for this long, and a host at most this often.
34
+ const FAILED_CHALLENGE = 10 * 60_000;
35
+ const VERIFICATIONS = { window: 60_000, max: 10 };
36
+ // How many hosts one principal may have challenged for the first time in a window.
37
+ const PROBES = { window: 10 * 60_000, max: 5 };
38
+ // Re-checks of access that fail this many times in a row end a subscription.
39
+ const UNANSWERED = 3;
40
+ // Requests are checked by their handlers, against the sketch.
41
+ const anything = { "~standard": { version: 1, vendor: "holon-sh", validate: value => ({ value: (value ?? {}) }) } };
42
+ const encoder = new TextEncoder();
43
+ const unbase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
44
+ const random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
45
+ const hex = (bytes) => [...new Uint8Array(bytes)].map(byte => byte.toString(16).padStart(2, "0")).join("");
46
+ // A Standard Webhooks symmetric secret: whsec_ and the base64 of 24 to 64 random bytes.
47
+ function secretBytes(secret) {
48
+ const match = typeof secret === "string" ? /^whsec_([A-Za-z0-9+/]+={0,2})$/.exec(secret) : null;
49
+ if (!match?.[1])
50
+ return undefined;
51
+ try {
52
+ const bytes = unbase64(match[1]);
53
+ return bytes.length >= 24 && bytes.length <= 64 ? bytes : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ // JSON with object keys in order, so arguments that are equal give the same subscription.
60
+ function canonical(value) {
61
+ if (Array.isArray(value))
62
+ return `[${value.map(canonical).join(",")}]`;
63
+ if (value && typeof value === "object") {
64
+ const fields = value;
65
+ return `{${Object.keys(fields).sort().filter(key => fields[key] !== undefined).map(key => `${JSON.stringify(key)}:${canonical(fields[key])}`).join(",")}}`;
66
+ }
67
+ return JSON.stringify(value);
68
+ }
69
+ function equal(a, b) {
70
+ if (typeof a !== "string" || a.length !== b.length)
71
+ return false;
72
+ let difference = 0;
73
+ for (let index = 0; index < a.length; index++)
74
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
75
+ return difference === 0;
76
+ }
77
+ // The sketch's lastError category for a POST that got no answer.
78
+ function category(error) {
79
+ const cause = error?.cause;
80
+ const name = error?.name;
81
+ if (name === "TimeoutError" || name === "AbortError" || /TIMEOUT/.test(cause?.code ?? ""))
82
+ return "timeout";
83
+ if (/TLS|SSL|CERT|SELF_SIGNED|UNABLE_TO|DEPTH_ZERO/.test(cause?.code ?? ""))
84
+ return "tls_error";
85
+ return "connection_refused";
86
+ }
87
+ /**
88
+ * POSTs a signed body to a callback and says how it went. It connects only to globally reachable addresses, checked
89
+ * when each connection is made, after the name is resolved, so a name that resolves to a private address later cannot
90
+ * be used to reach one; files' fetch does this, and is loaded when first needed, since it needs Node. Redirects are
91
+ * not followed, and the whole exchange has `timeout` milliseconds, so an endpoint that answers slowly cannot hold it
92
+ * open. Only the start of an answer is read, which a challenge's echo fits in.
93
+ */
94
+ async function post(url, headers, body, { timeout, local }) {
95
+ try {
96
+ const send = local ? fetch : (await import("#platform")).fetchGlobal;
97
+ const response = await send(url, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body, redirect: "manual", signal: AbortSignal.timeout(timeout) });
98
+ const { status } = response;
99
+ const ok = status >= 200 && status < 300;
100
+ let text = "";
101
+ if (response.body) {
102
+ const reader = response.body.getReader();
103
+ const decoder = new TextDecoder();
104
+ for (let read = await reader.read(); !read.done && text.length < 4096; read = await reader.read())
105
+ text += decoder.decode(read.value, { stream: true });
106
+ await reader.cancel().catch(() => { });
107
+ }
108
+ return { ok, status, body: text.slice(0, 4096), ...(!ok && { error: status >= 500 ? "http_5xx" : "http_4xx" }) };
109
+ }
110
+ catch (error) {
111
+ // A header fetch will not send, such as an eventId outside Latin-1, fails here too.
112
+ return { error: category(error) };
113
+ }
114
+ }
115
+ /**
116
+ * The webhook delivery mode, which createEvents adds when given a webhookTtl, the `ttl` here. Access is checked again
117
+ * every `heartbeat`, as push streams check it. It serves
118
+ * events/subscribe and events/unsubscribe to authenticated callers, verifies each callback by a challenge before
119
+ * delivering to it, signs each delivery per Standard Webhooks with the client's secret, retries failed ones, and lets
120
+ * subscriptions lapse when not refreshed.
121
+ */
122
+ export function webhooks({ ttl, heartbeat, allowInsecureLocalCallbacksForTesting = false }) {
123
+ const local = allowInsecureLocalCallbacksForTesting === true;
124
+ // Subscriptions being delivered to, by event name, then id; and what delivering to each has come to, which is kept
125
+ // in memory only, lost on restart and begun again.
126
+ const live = new Map();
127
+ const states = new WeakMap();
128
+ // Callback URLs each principal has proved it receives at, so their next subscriptions are not challenged again; URLs
129
+ // whose challenge failed; and when each host was challenged lately.
130
+ const verified = new Map();
131
+ const failed = new Map();
132
+ const challenged = new Map();
133
+ // The hosts each principal had challenged lately, so one caller cannot have the server reach many new hosts.
134
+ const probed = new Map();
135
+ // Deliveries running to each endpoint, and those waiting for one of them to finish.
136
+ const endpoints = new Map();
137
+ const attached = new WeakSet();
138
+ let internals;
139
+ const quietly = (promise) => void Promise.resolve(promise).catch(() => { });
140
+ const principalOf = (context) => {
141
+ const { principal } = getContext(context);
142
+ return principal && { issuer: principal.issuer, subject: principal.subject };
143
+ };
144
+ const owner = (principal, url) => canonical([principal.issuer, principal.subject, url]);
145
+ const endpointError = (reason) => new ProtocolError(CALLBACK_ENDPOINT_ERROR, "CallbackEndpointError", { reason });
146
+ const expired = (record) => record.expiresAt <= Date.now();
147
+ const refreshBefore = (record) => new Date(record.expiresAt).toISOString();
148
+ // Stops delivering to a subscription and lets go of its place among its type's subscriptions. A newer subscription
149
+ // with the same id is not this one's to remove.
150
+ const forget = async (record) => {
151
+ const state = states.get(record);
152
+ states.delete(record);
153
+ state?.opened?.close();
154
+ const records = live.get(record.name);
155
+ if (records?.get(record.id) !== record)
156
+ return;
157
+ records.delete(record.id);
158
+ };
159
+ // The safe-to-persist cursor: every event at or before it has been acknowledged or given up on.
160
+ const watermark = (state) => state.queue.length ? state.floor ?? null : internals.position(state.type);
161
+ // A few deliveries run to one endpoint at a time, so a slow or flooded one holds neither connections nor the rest.
162
+ const slot = async (url) => {
163
+ const { origin } = new URL(url);
164
+ let endpoint = endpoints.get(origin);
165
+ if (!endpoint)
166
+ endpoints.set(origin, endpoint = { running: 0, waiting: [] });
167
+ const current = endpoint;
168
+ if (current.running < CONCURRENCY)
169
+ current.running++;
170
+ else
171
+ await new Promise(resolve => current.waiting.push(resolve));
172
+ return () => {
173
+ const next = current.waiting.shift();
174
+ if (next)
175
+ return next();
176
+ if (--current.running === 0)
177
+ endpoints.delete(origin);
178
+ };
179
+ };
180
+ const send = async (record, id, body) => {
181
+ const at = new Date();
182
+ const timestamp = Math.floor(at.getTime() / 1000);
183
+ const secrets = [record.secret, ...(record.previous && record.previous.until > Date.now() ? [record.previous.secret] : [])];
184
+ // While a refresh's new secret settles in, both sign, as Standard Webhooks allows.
185
+ const signatures = secrets.map(secret => new Webhook(secret).sign(id, at, body));
186
+ const release = await slot(record.url);
187
+ try {
188
+ return await post(record.url, { "webhook-id": id, "webhook-timestamp": String(timestamp), "webhook-signature": signatures.join(" "), "X-MCP-Subscription-Id": record.id }, body, { timeout: TIMEOUT, local });
189
+ }
190
+ finally {
191
+ release();
192
+ }
193
+ };
194
+ const control = (type) => `msg_${type}_${hex(random(12))}`;
195
+ // Tells a callback its subscription has ended, and why. It no longer exists, so this is sent once.
196
+ const terminated = (record, error) => send(record, control("terminated"), JSON.stringify({
197
+ type: "terminated", error: { code: error.code, message: error.message, ...(error.data !== undefined && { data: error.data }) },
198
+ })).catch(() => { });
199
+ // Ends a subscription the server no longer serves.
200
+ const terminate = async (record, error) => {
201
+ if (!states.has(record))
202
+ return;
203
+ await forget(record).catch(() => { });
204
+ await terminated(record, error);
205
+ };
206
+ // Events skipped, while too many were in flight or because the source moved past them, are a gap the client hears
207
+ // of once the rest are through.
208
+ const flush = (record, state) => {
209
+ if (state.queue.length || !state.gap)
210
+ return;
211
+ state.gap = false;
212
+ const body = JSON.stringify({ type: "gap", cursor: watermark(state) });
213
+ quietly(attempt(record, { state, id: control("gap"), tries: 0, body: () => body }));
214
+ };
215
+ const settle = (record, entry) => {
216
+ entry.settled = true;
217
+ const { state } = entry;
218
+ for (let first = state.queue[0]; first?.settled; first = state.queue[0]) {
219
+ state.queue.shift();
220
+ state.floor = first.cursor;
221
+ }
222
+ flush(record, state);
223
+ };
224
+ // What the subscription's refresh reports of its deliveries.
225
+ const outcome = (state, result) => {
226
+ const now = new Date().toISOString();
227
+ if (result.ok)
228
+ Object.assign(state, { lastDeliveryAt: now, lastError: null, failedSince: undefined });
229
+ else
230
+ Object.assign(state, { lastError: result.error, failedSince: state.failedSince ?? now });
231
+ };
232
+ // Each message is retried on its own, with backoff, until it is acknowledged, refused for good, or out of attempts.
233
+ // While the caller's access is not known it waits, without spending its attempts.
234
+ const attempt = async (record, entry) => {
235
+ const { state } = entry;
236
+ // A message of a subscription since ended, or made again, is not sent.
237
+ if (states.get(record) !== state)
238
+ return;
239
+ if (expired(record))
240
+ return void (entry.event && settle(record, entry));
241
+ if (state.unknown)
242
+ return void state.held.push(entry);
243
+ let body;
244
+ try {
245
+ body = entry.body();
246
+ }
247
+ catch {
248
+ body = undefined;
249
+ }
250
+ // A body that cannot be made, or one too large for receivers, is given up on, as a receiver's 413 would be.
251
+ if (body === undefined || encoder.encode(body).length > MAX_BODY)
252
+ return void (entry.event && settle(record, entry));
253
+ const result = await send(record, entry.id, body);
254
+ if (states.get(record) !== state)
255
+ return;
256
+ // A receiver declining an event (410) or refusing its size (413) says nothing about the subscription, and is not retried.
257
+ const declined = result.status === 410 || result.status === 413;
258
+ if (!declined)
259
+ outcome(state, result);
260
+ if (result.ok || declined || ++entry.tries >= ATTEMPTS)
261
+ return void (entry.event && settle(record, entry));
262
+ if (state.unknown)
263
+ return void state.held.push(entry);
264
+ setTimeout(() => quietly(attempt(record, entry)), BACKOFF * 5 ** (entry.tries - 1)).unref?.();
265
+ };
266
+ const enqueue = (record, event) => {
267
+ const state = states.get(record);
268
+ if (!state)
269
+ return;
270
+ // Beyond what may be in flight, events are skipped, and the client is told of the gap.
271
+ if (state.queue.length >= MAX_PENDING)
272
+ return void (state.gap = true);
273
+ if (state.queue.some(entry => entry.id === event.eventId))
274
+ return;
275
+ const entry = { state, id: event.eventId, event, cursor: event.cursor, tries: 0, body: () => JSON.stringify({ ...event, cursor: state.queue[0] === entry ? entry.cursor : state.floor }) };
276
+ state.queue.push(entry);
277
+ quietly(attempt(record, entry));
278
+ };
279
+ // Begins delivering to a subscription from a cursor: it takes its place among its type's subscriptions, counted
280
+ // against the limits, with the type's onSubscribe run; `ready` runs once it has; and it is sent what it missed
281
+ // since the cursor first. An end that arrives meanwhile is kept, and once setup is done the subscription ends with
282
+ // it, having delivered nothing.
283
+ const begin = async (type, record, cursor, maxAgeMs, ready) => {
284
+ const state = { type, queue: [], held: [], gap: false, unknown: 0 };
285
+ let ending;
286
+ const end = (error) => {
287
+ if (states.get(record) === state)
288
+ quietly(terminate(record, error));
289
+ else
290
+ ending ??= error;
291
+ };
292
+ const opened = await internals.open(type, { id: record.id, delivery: "webhook", arguments: record.subscription.arguments, context: record.subscription.context }, end);
293
+ state.opened = opened;
294
+ let missed;
295
+ try {
296
+ await ready?.();
297
+ // Events emitted while it is read are in what it returns, and none are emitted between it and the sink seeing
298
+ // this subscription, as reading emitted events does not wait.
299
+ const reading = internals.read(type, record.subscription, cursor ?? null, { maxAgeMs });
300
+ missed = reading instanceof Promise ? await reading : reading;
301
+ // Set by `end` while this waited, which the narrowing above does not see.
302
+ const ended = ending;
303
+ if (ended)
304
+ throw new ProtocolError(ended.code, ended.message, ended.data);
305
+ }
306
+ catch (error) {
307
+ opened.close();
308
+ throw error;
309
+ }
310
+ state.floor = missed.start;
311
+ states.set(record, state);
312
+ let records = live.get(record.name);
313
+ if (!records)
314
+ live.set(record.name, records = new Map());
315
+ const prior = records.get(record.id);
316
+ records.set(record.id, record);
317
+ if (prior && prior !== record)
318
+ quietly(forget(prior));
319
+ for (const event of missed.events)
320
+ enqueue(record, event);
321
+ return missed;
322
+ };
323
+ // Each emitted event goes to the subscriptions of its type it is for.
324
+ const attach = (type) => {
325
+ if (attached.has(type))
326
+ return;
327
+ attached.add(type);
328
+ type.sinks.add(emitted => {
329
+ for (const record of [...(live.get(type.name)?.values() ?? [])]) {
330
+ if (states.get(record)?.type !== type)
331
+ continue;
332
+ if (expired(record)) {
333
+ quietly(forget(record));
334
+ continue;
335
+ }
336
+ const event = internals.shape(type, emitted, record.subscription);
337
+ if (event)
338
+ enqueue(record, event);
339
+ }
340
+ });
341
+ };
342
+ // Expired subscriptions and stale verifications are let go of now and then, without keeping the process alive.
343
+ const sweep = () => {
344
+ const now = Date.now();
345
+ for (const records of live.values())
346
+ for (const record of records.values())
347
+ if (expired(record))
348
+ quietly(forget(record));
349
+ for (const [key, until] of verified)
350
+ if (until <= now)
351
+ verified.delete(key);
352
+ for (const [url, { until }] of failed)
353
+ if (until <= now)
354
+ failed.delete(url);
355
+ for (const [host, times] of challenged)
356
+ if ((times.at(-1) ?? 0) <= now - VERIFICATIONS.window)
357
+ challenged.delete(host);
358
+ for (const [who, hosts] of probed)
359
+ if ([...hosts.values()].every(at => at <= now - PROBES.window))
360
+ probed.delete(who);
361
+ };
362
+ // Access is checked again every `heartbeat` milliseconds, so a subscription whose caller lost it stops, whether or
363
+ // not it expires. A check that throws is no answer: the subscription's messages wait until one comes, and after a
364
+ // few in a row it ends with the error. One subscription's check never stops the others'.
365
+ let checking = false;
366
+ const recheck = async () => {
367
+ if (checking)
368
+ return;
369
+ checking = true;
370
+ try {
371
+ for (const records of [...live.values()])
372
+ for (const record of [...records.values()]) {
373
+ const state = states.get(record);
374
+ if (!state?.type.authorize)
375
+ continue;
376
+ try {
377
+ let holds;
378
+ try {
379
+ holds = await state.type.authorize(record.subscription.arguments, record.subscription.context);
380
+ }
381
+ catch (error) {
382
+ if (states.get(record) !== state)
383
+ continue;
384
+ const { code, message, data } = (error ?? {});
385
+ if (++state.unknown >= UNANSWERED)
386
+ await terminate(record, { code: Number.isInteger(code) ? code : INTERNAL_ERROR, message: message || "InternalError", ...(data !== undefined && { data }) });
387
+ continue;
388
+ }
389
+ if (states.get(record) !== state)
390
+ continue;
391
+ if (!holds) {
392
+ await terminate(record, new ProtocolError(FORBIDDEN, "Forbidden"));
393
+ continue;
394
+ }
395
+ if (state.unknown) {
396
+ state.unknown = 0;
397
+ for (const entry of state.held.splice(0))
398
+ quietly(attempt(record, entry));
399
+ }
400
+ }
401
+ catch {
402
+ // Ending one subscription failed; the others are still checked.
403
+ }
404
+ }
405
+ }
406
+ finally {
407
+ checking = false;
408
+ }
409
+ };
410
+ // Expiry and access are checked from the first time a server is served, without keeping the process alive.
411
+ let started = false;
412
+ const start = (given) => {
413
+ internals = given;
414
+ if (started)
415
+ return;
416
+ started = true;
417
+ setInterval(sweep, 60_000).unref?.();
418
+ setInterval(() => quietly(recheck()), heartbeat).unref?.();
419
+ };
420
+ // The endpoint proves it wants deliveries by echoing a challenge, signed like any delivery. A URL whose challenge
421
+ // failed is not challenged again for a while, and a host only so often, so subscribing cannot be used to make the
422
+ // server POST wherever a caller likes.
423
+ const verify = async (principal, url, id, secret) => {
424
+ const key = owner(principal, url);
425
+ const now = Date.now();
426
+ if ((verified.get(key) ?? 0) > now)
427
+ return;
428
+ const failure = failed.get(url);
429
+ if (failure && failure.until > now)
430
+ throw endpointError(failure.reason);
431
+ const { host } = new URL(url);
432
+ const recent = (challenged.get(host) ?? []).filter(at => at > now - VERIFICATIONS.window);
433
+ if (recent.length >= VERIFICATIONS.max)
434
+ throw new ProtocolError(RESOURCE_EXHAUSTED, "ResourceExhausted", { limit: "verifications", max: VERIFICATIONS.max });
435
+ const who = canonical([principal.issuer, principal.subject]);
436
+ const hosts = probed.get(who) ?? new Map();
437
+ for (const [each, at] of hosts)
438
+ if (at <= now - PROBES.window)
439
+ hosts.delete(each);
440
+ if (!hosts.has(host) && hosts.size >= PROBES.max)
441
+ throw new ProtocolError(RESOURCE_EXHAUSTED, "ResourceExhausted", { limit: "verificationHosts", max: PROBES.max });
442
+ probed.set(who, hosts.set(host, now));
443
+ challenged.set(host, [...recent, now]);
444
+ const challenge = hex(random(24));
445
+ const result = await send({ id, url, secret }, control("verification"), JSON.stringify({ type: "verification", challenge }));
446
+ let echoed;
447
+ try {
448
+ echoed = JSON.parse(result.body ?? "").challenge;
449
+ }
450
+ catch { }
451
+ if (!result.ok || !equal(echoed, challenge)) {
452
+ const reason = result.ok ? "challenge_failed" : result.error ?? "connection_refused";
453
+ failed.set(url, { until: Date.now() + FAILED_CHALLENGE, reason });
454
+ throw endpointError(reason);
455
+ }
456
+ verified.set(key, Date.now() + Math.max(ttl, 24 * 60 * 60_000));
457
+ };
458
+ // The subscription a request names, checked as the events of every mode are, with the type's authorization. Its
459
+ // id is made from the key the type gives the caller, event and arguments, and the callback URL. A caller with a
460
+ // principal always has that key.
461
+ const keyed = async (request, context) => {
462
+ const principal = principalOf(context);
463
+ if (!principal)
464
+ throw new ProtocolError(FORBIDDEN, "Forbidden", { reason: "Webhook delivery needs an authenticated principal." });
465
+ const url = request.delivery?.url;
466
+ let parsed;
467
+ try {
468
+ parsed = new URL(String(url));
469
+ }
470
+ catch { }
471
+ if (typeof url !== "string" || !parsed || parsed.protocol !== "https:" && !(local && parsed.protocol === "http:") || parsed.username || parsed.password)
472
+ throw new ProtocolError(INVALID_PARAMS, "delivery.url must be an https URL.");
473
+ const { type, subscription } = await internals.subscription(request, context, "webhook");
474
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify([subscription.lease, url])));
475
+ const id = `sub_${hex(digest).slice(0, 32)}`;
476
+ return { type, principal, url, id, subscription: { id, arguments: subscription.arguments, context: subscription.context } };
477
+ };
478
+ // The granted lifetime: the client's suggestion within the server's floor and cap. A request for no expiry, which
479
+ // needs storage the server does not have, is granted the cap.
480
+ const grant = (suggested) => Math.max(Math.min(MIN_TTL, ttl), Math.min(typeof suggested === "number" && suggested > 0 ? suggested : ttl, ttl));
481
+ return {
482
+ name: "webhook",
483
+ serve(served, given) {
484
+ const server = served;
485
+ start(given);
486
+ for (const type of internals.types.values())
487
+ attach(type);
488
+ server.setRequestHandler("events/subscribe", { params: anything }, async (request, ctx) => {
489
+ if (request.delivery?.mode !== undefined && request.delivery.mode !== "webhook")
490
+ throw new ProtocolError(INVALID_PARAMS, "delivery.mode must be webhook.");
491
+ const secret = request.delivery?.secret;
492
+ if (typeof secret !== "string" || !secretBytes(secret))
493
+ throw new ProtocolError(INVALID_PARAMS, "delivery.secret must be whsec_ and the base64 of 24 to 64 bytes.");
494
+ const { type, subscription, principal, url, id } = await keyed(request, ctx.http);
495
+ attach(type);
496
+ const expiresAt = Date.now() + grant(request.ttlMs);
497
+ const existing = live.get(type.name)?.get(id);
498
+ const state = existing && states.get(existing);
499
+ if (existing && state?.type === type && !expired(existing)) {
500
+ // A refresh: the TTL is granted again, and a new secret replaces the old. Delivery is never suspended.
501
+ const deliveryStatus = { active: true, lastDeliveryAt: state.lastDeliveryAt ?? null, lastError: state.lastError ?? null, ...(state.failedSince && { failedSince: state.failedSince }) };
502
+ Object.assign(existing, {
503
+ expiresAt,
504
+ subscription,
505
+ ...(existing.secret !== secret && { secret, previous: { secret: existing.secret, until: Date.now() + ROTATION_GRACE } }),
506
+ });
507
+ return { id, refreshBefore: refreshBefore(existing), cursor: watermark(state), truncated: false, deliveryStatus };
508
+ }
509
+ const record = { id, principal, url, name: type.name, secret, expiresAt, subscription };
510
+ // A new subscription is counted against the limits before its endpoint is challenged, and starts from the
511
+ // client's cursor: what it missed since is delivered first.
512
+ const missed = await begin(type, record, request.cursor, request.maxAgeMs, () => verify(principal, url, id, secret));
513
+ const started = states.get(record);
514
+ return { id, refreshBefore: refreshBefore(record), cursor: started ? watermark(started) : missed.cursor, truncated: missed.truncated };
515
+ });
516
+ server.setRequestHandler("events/unsubscribe", { params: anything }, async (request, ctx) => {
517
+ const { type, id } = await keyed(request, ctx.http);
518
+ const record = live.get(type.name)?.get(id);
519
+ if (!record || expired(record))
520
+ throw new ProtocolError(NOT_FOUND, "NotFound", { kind: "subscription" });
521
+ await forget(record);
522
+ return {};
523
+ });
524
+ },
525
+ };
526
+ }
527
+ //# sourceMappingURL=webhooks.js.map