slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/webhooks.js ADDED
@@ -0,0 +1,300 @@
1
+ // Webhook delivery of the event feed (RFC 0002 P1-5): each change is a CloudEvents 1.0 envelope,
2
+ // signed and headed as Standard Webhooks asks (webhook-id, webhook-timestamp, webhook-signature),
3
+ // sent to each endpoint in feed order, one at a time, and retried on a growing schedule until the
4
+ // endpoint has failed for a day. The store keeps the position and the schedule (event-feed.ts), so
5
+ // a worker that stops mid-batch loses nothing: the next one sends the rest, with the same ids.
6
+ import { createHmac } from 'node:crypto';
7
+ import { lookup as dnsLookup } from 'node:dns';
8
+ import { Agent as HttpAgent, request as httpRequest } from 'node:http';
9
+ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
10
+ import { BlockList, isIP } from 'node:net';
11
+ import { ignoreRejection } from './hooks.js';
12
+ /** A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`. */
13
+ export function slotlockCloudEvent(change) {
14
+ return {
15
+ specversion: '1.0',
16
+ id: change.id,
17
+ source: change.resourceId ? `/slotlock/resources/${change.resourceId}` : '/slotlock',
18
+ type: `io.pylota.slotlock.${change.type}`,
19
+ subject: change.subject,
20
+ time: change.occurredAt.toISOString(),
21
+ datacontenttype: 'application/json',
22
+ data: change.data,
23
+ };
24
+ }
25
+ /**
26
+ * The Standard Webhooks signature of one attempt: `v1,` and the base64 HMAC-SHA256, keyed by the
27
+ * secret's bytes, of `id.timestamp.body`.
28
+ */
29
+ export function signSlotlockWebhook(params) {
30
+ if (!params.secret.startsWith('whsec_'))
31
+ throw new TypeError('Slotlock webhook secret must start with whsec_');
32
+ const key = Buffer.from(params.secret.slice('whsec_'.length), 'base64');
33
+ const signature = createHmac('sha256', key)
34
+ .update(`${params.id}.${params.timestamp}.${params.body}`)
35
+ .digest('base64');
36
+ return `v1,${signature}`;
37
+ }
38
+ /**
39
+ * When to try a failed endpoint again, by how many attempts have failed in a row: the Standard
40
+ * Webhooks example schedule (5 s, 5 min, 30 min, 2 h, 5 h, then 10 h), with ±10% jitter, and never
41
+ * sooner than the endpoint's Retry-After (capped at a day).
42
+ */
43
+ export function slotlockWebhookRetryDelayMs(attempts, retryAfterMs, random = Math.random) {
44
+ const schedule = [5_000, 300_000, 1_800_000, 7_200_000, 18_000_000, 36_000_000];
45
+ const base = schedule[Math.min(Math.max(attempts, 1), schedule.length) - 1];
46
+ const jittered = Math.round(base * (0.9 + random() * 0.2));
47
+ return Math.max(jittered, Math.min(retryAfterMs ?? 0, 86_400_000));
48
+ }
49
+ // One list per family: a BlockList also checks an IPv4 address against IPv6 rules through its
50
+ // mapped form, so ::ffff:0:0/96 in the same list would match every IPv4 address.
51
+ const PRIVATE_IPV4 = new BlockList();
52
+ for (const [address, prefix] of [
53
+ ['0.0.0.0', 8],
54
+ ['10.0.0.0', 8],
55
+ ['100.64.0.0', 10],
56
+ ['127.0.0.0', 8],
57
+ ['169.254.0.0', 16],
58
+ ['172.16.0.0', 12],
59
+ ['192.0.0.0', 24],
60
+ ['192.168.0.0', 16],
61
+ ['198.18.0.0', 15],
62
+ ['224.0.0.0', 3],
63
+ ]) {
64
+ PRIVATE_IPV4.addSubnet(address, prefix, 'ipv4');
65
+ }
66
+ const PRIVATE_IPV6 = new BlockList();
67
+ for (const [address, prefix] of [
68
+ ['::', 96], // unspecified, loopback and IPv4-compatible
69
+ ['::ffff:0:0:0', 96], // IPv4-translated
70
+ ['64:ff9b::', 96], // NAT64
71
+ ['64:ff9b:1::', 48], // local-use NAT64
72
+ ['100::', 64], // discard
73
+ ['2001::', 32], // Teredo
74
+ ['2002::', 16], // 6to4
75
+ ['fc00::', 7], // unique local
76
+ ['fe80::', 10], // link-local
77
+ ['fec0::', 10], // site-local
78
+ ['ff00::', 8], // multicast
79
+ ]) {
80
+ PRIVATE_IPV6.addSubnet(address, prefix, 'ipv6');
81
+ }
82
+ /** Whether `address` is one a webhook may not reach: loopback, private, link-local, and the like. */
83
+ export function isPrivateAddress(address) {
84
+ const family = isIP(address);
85
+ if (family === 4)
86
+ return PRIVATE_IPV4.check(address, 'ipv4');
87
+ if (family !== 6)
88
+ return true;
89
+ // An IPv4-mapped address (::ffff:a.b.c.d, or its hex form) is the IPv4 address it carries.
90
+ const mapped = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(address);
91
+ if (mapped) {
92
+ if (mapped[1])
93
+ return PRIVATE_IPV4.check(mapped[1], 'ipv4');
94
+ const high = Number.parseInt(mapped[2], 16);
95
+ const low = Number.parseInt(mapped[3], 16);
96
+ return PRIVATE_IPV4.check(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`, 'ipv4');
97
+ }
98
+ return PRIVATE_IPV6.check(address, 'ipv6');
99
+ }
100
+ /**
101
+ * POST with Node's http client: no redirect is followed, the answer's body is never read beyond
102
+ * 64 KiB, and every address the host resolves to is checked when the socket connects, so a name
103
+ * that resolves to a private network (or is rebound to one) is refused unless `allowPrivateNetworks`.
104
+ */
105
+ export function createSlotlockWebhookSender(options = {}) {
106
+ const timeoutMs = options.timeoutMs ?? 15_000;
107
+ const lookup = ((hostname, lookupOptions, callback) => {
108
+ dnsLookup(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
109
+ if (error)
110
+ return callback(error);
111
+ const list = addresses;
112
+ if (!options.allowPrivateNetworks && list.some(({ address }) => isPrivateAddress(address))) {
113
+ return callback(Object.assign(new Error(`webhook host ${hostname} resolves to a private address`), { code: 'webhook_private_address' }));
114
+ }
115
+ const all = lookupOptions.all;
116
+ if (all)
117
+ return callback(null, list);
118
+ const first = list[0];
119
+ return first ? callback(null, first.address, first.family) : callback(new Error('no address'));
120
+ });
121
+ });
122
+ return (request) => new Promise((resolve, reject) => {
123
+ const url = new URL(request.url);
124
+ if (isIP(url.hostname.replace(/^\[|\]$/g, '')) && !options.allowPrivateNetworks) {
125
+ if (isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ''))) {
126
+ reject(Object.assign(new Error('webhook URL names a private address'), { code: 'webhook_private_address' }));
127
+ return;
128
+ }
129
+ }
130
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
131
+ const outgoing = send(url, {
132
+ method: 'POST',
133
+ headers: { ...request.headers, 'Content-Length': String(Buffer.byteLength(request.body)) },
134
+ // An agent of its own, never the global one: with NODE_USE_ENV_PROXY a proxy from the
135
+ // environment would connect for us, and the address check below would never run.
136
+ agent: url.protocol === 'https:' ? new HttpsAgent({ lookup }) : new HttpAgent({ lookup }),
137
+ lookup,
138
+ timeout: timeoutMs,
139
+ }, (response) => {
140
+ let read = 0;
141
+ response.on('data', (chunk) => {
142
+ read += chunk.length;
143
+ if (read > 65_536)
144
+ response.destroy();
145
+ });
146
+ const finish = () => {
147
+ const retryAfter = Number(response.headers['retry-after']);
148
+ resolve({
149
+ status: response.statusCode ?? 0,
150
+ ...(Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfterMs: retryAfter * 1_000 } : {}),
151
+ });
152
+ };
153
+ response.on('end', finish);
154
+ response.on('close', finish);
155
+ });
156
+ const timedOut = () => outgoing.destroy(Object.assign(new Error('webhook request timed out'), { code: 'webhook_timeout' }));
157
+ outgoing.on('timeout', timedOut);
158
+ // `timeout` is idleness only: an answer dripped a byte at a time would hold the request
159
+ // open for hours. The whole exchange has this long.
160
+ const deadline = setTimeout(timedOut, timeoutMs);
161
+ deadline.unref?.();
162
+ outgoing.on('close', () => clearTimeout(deadline));
163
+ outgoing.on('error', reject);
164
+ outgoing.end(request.body);
165
+ });
166
+ }
167
+ /**
168
+ * One delivery pass for a tenant: claim each endpoint with changes due, send them in order until
169
+ * one fails, and record how far each got. A 2xx is delivered; 410 disables the endpoint; anything
170
+ * else, or no answer, is retried on the schedule. The send happens outside any transaction.
171
+ */
172
+ export async function deliverSlotlockWebhooks(params) {
173
+ const now = params.now ?? Date.now;
174
+ const claims = await params.feed.claimWebhookDeliveries({
175
+ tenantRef: params.tenantRef,
176
+ leaseMs: params.leaseMs ?? 60_000,
177
+ batch: params.batch ?? 20,
178
+ });
179
+ let delivered = 0;
180
+ let failed = 0;
181
+ const stopAt = now() + (params.budgetMs ?? 30_000);
182
+ await Promise.all(claims.map(async (claim) => {
183
+ let last;
184
+ let failure;
185
+ // The failures before this pass set the delay: the schedule grows across passes.
186
+ let attempts = claim.endpoint.attempts;
187
+ for (const change of claim.changes) {
188
+ if (now() >= stopAt)
189
+ break;
190
+ const body = JSON.stringify(slotlockCloudEvent(change));
191
+ const timestamp = Math.floor(now() / 1_000);
192
+ try {
193
+ const response = await params.send({
194
+ url: claim.endpoint.url,
195
+ headers: {
196
+ 'Content-Type': 'application/cloudevents+json',
197
+ 'User-Agent': 'Slotlock-Webhooks/1',
198
+ 'webhook-id': change.id,
199
+ 'webhook-timestamp': String(timestamp),
200
+ 'webhook-signature': signSlotlockWebhook({
201
+ secret: claim.endpoint.secret,
202
+ id: change.id,
203
+ timestamp,
204
+ body,
205
+ }),
206
+ },
207
+ body,
208
+ });
209
+ if (response.status >= 200 && response.status < 300) {
210
+ last = change.position;
211
+ delivered++;
212
+ continue;
213
+ }
214
+ attempts = last ? 1 : attempts + 1;
215
+ failure = {
216
+ status: response.status,
217
+ error: `HTTP ${response.status}`,
218
+ retryInMs: slotlockWebhookRetryDelayMs(attempts, response.retryAfterMs),
219
+ ...(response.status === 410 ? { gone: true } : {}),
220
+ };
221
+ }
222
+ catch (error) {
223
+ attempts = last ? 1 : attempts + 1;
224
+ const code = error.code;
225
+ failure = {
226
+ status: null,
227
+ error: typeof code === 'string' ? code : 'webhook_unreachable',
228
+ retryInMs: slotlockWebhookRetryDelayMs(attempts),
229
+ };
230
+ }
231
+ break;
232
+ }
233
+ if (failure)
234
+ failed++;
235
+ await params.feed.settleWebhookDelivery({
236
+ tenantRef: params.tenantRef,
237
+ endpointId: claim.endpoint.id,
238
+ leaseToken: claim.leaseToken,
239
+ ...(last ? { delivered: last } : {}),
240
+ ...(failure ? { failure } : {}),
241
+ });
242
+ }));
243
+ return { delivered, failed };
244
+ }
245
+ /**
246
+ * Delivery passes every `intervalMs` (250-60,000, default 2,000) for the tenants `tenants()` lists
247
+ * (the store's webhook_tenants(), say) and those touched, one tenant at a time. A pass that fails
248
+ * is reported and the next one runs as usual.
249
+ */
250
+ export function startSlotlockWebhookDelivery(options) {
251
+ const intervalMs = options.intervalMs ?? 2_000;
252
+ if (!(Number.isSafeInteger(intervalMs) && intervalMs >= 250 && intervalMs <= 60_000)) {
253
+ throw new RangeError('Slotlock webhook interval must be 250-60,000 ms');
254
+ }
255
+ const touched = new Set();
256
+ let closed = false;
257
+ let running = null;
258
+ const concurrency = options.concurrency ?? 8;
259
+ if (!(Number.isSafeInteger(concurrency) && concurrency >= 1 && concurrency <= 64)) {
260
+ throw new RangeError('Slotlock webhook concurrency must be 1-64');
261
+ }
262
+ const pass = async () => {
263
+ const queue = [...new Set([...(await options.tenants()), ...touched])];
264
+ const worker = async () => {
265
+ for (let tenantRef = queue.shift(); tenantRef !== undefined && !closed; tenantRef = queue.shift()) {
266
+ try {
267
+ const result = await options.deliver(tenantRef);
268
+ if (result.delivered + result.failed > 0)
269
+ options.onPass?.(tenantRef, result);
270
+ }
271
+ catch (error) {
272
+ options.onError?.(error);
273
+ }
274
+ }
275
+ };
276
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
277
+ };
278
+ const timer = setInterval(() => {
279
+ // A pass still running skips this tick rather than queue another behind it.
280
+ if (running || closed)
281
+ return;
282
+ running = pass()
283
+ .catch((error) => options.onError?.(error))
284
+ .finally(() => {
285
+ running = null;
286
+ });
287
+ ignoreRejection(running);
288
+ }, intervalMs);
289
+ timer.unref?.();
290
+ return {
291
+ touch(tenantRef) {
292
+ touched.add(tenantRef);
293
+ },
294
+ async close() {
295
+ closed = true;
296
+ clearInterval(timer);
297
+ await running;
298
+ },
299
+ };
300
+ }