slotlock 0.3.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.
- package/CHANGELOG.md +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SlotlockBookingRule, SlotlockBookingRules } from './booking-rules.js';
|
|
1
2
|
/** Half-open time interval `[start, end)`. */
|
|
2
3
|
export interface Interval {
|
|
3
4
|
start: Date;
|
|
@@ -29,6 +30,13 @@ export interface SlotlockResource {
|
|
|
29
30
|
visibility?: 'shared' | 'private';
|
|
30
31
|
/** When the tenant archived it: no longer listed or booked, its history kept. Absent in use. */
|
|
31
32
|
archivedAt?: Date;
|
|
33
|
+
/** What a booking on it must satisfy (`SlotlockBookingRules`). Absent when it has none. */
|
|
34
|
+
bookingRules?: SlotlockBookingRules;
|
|
35
|
+
/**
|
|
36
|
+
* The pool it belongs to: interchangeable resources sharing a name, such as a vehicle class or a
|
|
37
|
+
* room type, booked by the class (`slotlock_book_pool`). Absent when it is in none.
|
|
38
|
+
*/
|
|
39
|
+
pool?: string;
|
|
32
40
|
}
|
|
33
41
|
/** A confirmed occupation of a resource. The DB EXCLUDE constraint is the overlap arbiter. */
|
|
34
42
|
export interface SlotlockReservation {
|
|
@@ -115,6 +123,12 @@ export type ExternalReservationResult = {
|
|
|
115
123
|
ok: false;
|
|
116
124
|
code: 'reservation_limit_reached';
|
|
117
125
|
limit: number;
|
|
126
|
+
}
|
|
127
|
+
/** The booking breaks one of the resource's booking rules (`SlotlockResource.bookingRules`). */
|
|
128
|
+
| {
|
|
129
|
+
ok: false;
|
|
130
|
+
code: 'booking_rule_violated';
|
|
131
|
+
rule: SlotlockBookingRule;
|
|
118
132
|
} | {
|
|
119
133
|
ok: false;
|
|
120
134
|
code: 'overlap';
|
|
@@ -193,6 +207,12 @@ export type CreateReservationResult = {
|
|
|
193
207
|
ok: false;
|
|
194
208
|
code: 'reservation_limit_reached';
|
|
195
209
|
limit: number;
|
|
210
|
+
}
|
|
211
|
+
/** The booking breaks one of the resource's booking rules (`SlotlockResource.bookingRules`). */
|
|
212
|
+
| {
|
|
213
|
+
ok: false;
|
|
214
|
+
code: 'booking_rule_violated';
|
|
215
|
+
rule: SlotlockBookingRule;
|
|
196
216
|
} | {
|
|
197
217
|
ok: false;
|
|
198
218
|
code: 'overlap';
|
|
@@ -208,6 +228,24 @@ export interface SlotlockHold {
|
|
|
208
228
|
bufferAfterMs: number;
|
|
209
229
|
expiresAt: Date;
|
|
210
230
|
source: string | null;
|
|
231
|
+
/** The alternatives it was held with (`groupId` when acquired); absent when held alone. */
|
|
232
|
+
groupId?: string;
|
|
233
|
+
}
|
|
234
|
+
/** One entry of a tenant's agenda (`listAgenda`): what occupies a resource, and from when. */
|
|
235
|
+
export interface SlotlockAgendaEntry {
|
|
236
|
+
id: string;
|
|
237
|
+
resourceId: string;
|
|
238
|
+
start: Date;
|
|
239
|
+
end: Date;
|
|
240
|
+
/** A booking, a calendar event's occurrence, or a live hold. */
|
|
241
|
+
kind: 'booking' | 'event' | 'hold';
|
|
242
|
+
/** The booking's reference; null for an event's occurrence and a hold. */
|
|
243
|
+
reference: string | null;
|
|
244
|
+
/** The calendar event's summary; null for a booking and a hold. */
|
|
245
|
+
summary: string | null;
|
|
246
|
+
/** When the hold lapses; null for a booking and an event. */
|
|
247
|
+
expiresAt: Date | null;
|
|
248
|
+
source: string | null;
|
|
211
249
|
}
|
|
212
250
|
export type AcquireHoldResult =
|
|
213
251
|
/** `idempotent`: a retry of a keyed hold, answered with the hold the first request made. */
|
|
@@ -258,6 +296,21 @@ export type AcquireHoldResult =
|
|
|
258
296
|
ok: false;
|
|
259
297
|
code: 'hold_limit_reached';
|
|
260
298
|
limit: number;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The holder's live holds would together hold more hours than `maxHeldHoursPerHolder` allows
|
|
302
|
+
* (`limit`, in hours): one agent cannot keep a resource's month from everyone, a hold at a time.
|
|
303
|
+
*/
|
|
304
|
+
| {
|
|
305
|
+
ok: false;
|
|
306
|
+
code: 'held_time_limit_reached';
|
|
307
|
+
limit: number;
|
|
308
|
+
}
|
|
309
|
+
/** The booking breaks one of the resource's booking rules (`SlotlockResource.bookingRules`). */
|
|
310
|
+
| {
|
|
311
|
+
ok: false;
|
|
312
|
+
code: 'booking_rule_violated';
|
|
313
|
+
rule: SlotlockBookingRule;
|
|
261
314
|
} | {
|
|
262
315
|
ok: false;
|
|
263
316
|
code: 'overlap';
|
|
@@ -387,6 +440,24 @@ export interface PutCalendarEventCommand extends CalendarEventContent {
|
|
|
387
440
|
/** Mandatory for recurrence; bounded to 367 days. Defaults to the event interval otherwise. */
|
|
388
441
|
materializationWindow?: Interval;
|
|
389
442
|
source?: string;
|
|
443
|
+
/**
|
|
444
|
+
* Judge the event as a booking made now against the resource's booking rules (its first
|
|
445
|
+
* occurrence for notice and advance, every occurrence for step and hours), and keep the
|
|
446
|
+
* resource's turnaround after each occurrence. Off by default: an imported event is what happened
|
|
447
|
+
* elsewhere. The agent tools set it.
|
|
448
|
+
*/
|
|
449
|
+
enforceBookingRules?: boolean;
|
|
450
|
+
/**
|
|
451
|
+
* Book the time a live hold keeps: the hold must be `holderRef`'s (`acquireHold`'s
|
|
452
|
+
* `principalRef`), on this resource, and cover the event, a single occurrence. In one transaction
|
|
453
|
+
* the hold and the rest of its group are released and the event takes the time, so no rival can
|
|
454
|
+
* come between. Its notice and advance were judged when it was held; step and hours are judged
|
|
455
|
+
* again.
|
|
456
|
+
*/
|
|
457
|
+
fromHold?: {
|
|
458
|
+
holdId: string;
|
|
459
|
+
holderRef: string;
|
|
460
|
+
};
|
|
390
461
|
}
|
|
391
462
|
export type PutCalendarEventResult = {
|
|
392
463
|
ok: true;
|
|
@@ -438,6 +509,16 @@ export type PutCalendarEventResult = {
|
|
|
438
509
|
code: 'revision_conflict';
|
|
439
510
|
currentRevision: number;
|
|
440
511
|
}
|
|
512
|
+
/** `fromHold` names no live hold of that holder's: released, booked, expired, or another's. */
|
|
513
|
+
| {
|
|
514
|
+
ok: false;
|
|
515
|
+
code: 'hold_not_found';
|
|
516
|
+
}
|
|
517
|
+
/** `fromHold`'s hold is on another resource, or does not cover the event. */
|
|
518
|
+
| {
|
|
519
|
+
ok: false;
|
|
520
|
+
code: 'hold_mismatch';
|
|
521
|
+
}
|
|
441
522
|
/**
|
|
442
523
|
* A recurring event on the resource is not stored as far as this write reaches, and cannot be
|
|
443
524
|
* extended that far (past the rolling horizon, or blocked): the time cannot be proven free.
|
|
@@ -445,6 +526,12 @@ export type PutCalendarEventResult = {
|
|
|
445
526
|
| {
|
|
446
527
|
ok: false;
|
|
447
528
|
code: 'recurrence_not_materialized';
|
|
529
|
+
}
|
|
530
|
+
/** The booking breaks one of the resource's booking rules (`SlotlockResource.bookingRules`). */
|
|
531
|
+
| {
|
|
532
|
+
ok: false;
|
|
533
|
+
code: 'booking_rule_violated';
|
|
534
|
+
rule: SlotlockBookingRule;
|
|
448
535
|
} | {
|
|
449
536
|
ok: false;
|
|
450
537
|
code: 'overlap';
|
package/types.js
CHANGED
package/webhooks.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SlotlockChange, SlotlockEventFeed } from './event-feed.js';
|
|
2
|
+
/** The CloudEvents envelope a change is delivered and listed as. */
|
|
3
|
+
export interface SlotlockCloudEvent {
|
|
4
|
+
specversion: '1.0';
|
|
5
|
+
id: string;
|
|
6
|
+
source: string;
|
|
7
|
+
type: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
time: string;
|
|
10
|
+
datacontenttype: 'application/json';
|
|
11
|
+
data: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`. */
|
|
14
|
+
export declare function slotlockCloudEvent(change: SlotlockChange): SlotlockCloudEvent;
|
|
15
|
+
/**
|
|
16
|
+
* The Standard Webhooks signature of one attempt: `v1,` and the base64 HMAC-SHA256, keyed by the
|
|
17
|
+
* secret's bytes, of `id.timestamp.body`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function signSlotlockWebhook(params: {
|
|
20
|
+
secret: string;
|
|
21
|
+
id: string;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
body: string;
|
|
24
|
+
}): string;
|
|
25
|
+
/**
|
|
26
|
+
* When to try a failed endpoint again, by how many attempts have failed in a row: the Standard
|
|
27
|
+
* Webhooks example schedule (5 s, 5 min, 30 min, 2 h, 5 h, then 10 h), with ±10% jitter, and never
|
|
28
|
+
* sooner than the endpoint's Retry-After (capped at a day).
|
|
29
|
+
*/
|
|
30
|
+
export declare function slotlockWebhookRetryDelayMs(attempts: number, retryAfterMs?: number, random?: () => number): number;
|
|
31
|
+
/** Whether `address` is one a webhook may not reach: loopback, private, link-local, and the like. */
|
|
32
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
33
|
+
export interface SlotlockWebhookResponse {
|
|
34
|
+
status: number;
|
|
35
|
+
/** From the response's Retry-After, when it gave one in seconds. */
|
|
36
|
+
retryAfterMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export type SlotlockWebhookSender = (request: {
|
|
39
|
+
url: string;
|
|
40
|
+
headers: Record<string, string>;
|
|
41
|
+
body: string;
|
|
42
|
+
}) => Promise<SlotlockWebhookResponse>;
|
|
43
|
+
/**
|
|
44
|
+
* POST with Node's http client: no redirect is followed, the answer's body is never read beyond
|
|
45
|
+
* 64 KiB, and every address the host resolves to is checked when the socket connects, so a name
|
|
46
|
+
* that resolves to a private network (or is rebound to one) is refused unless `allowPrivateNetworks`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createSlotlockWebhookSender(options?: {
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
allowPrivateNetworks?: boolean;
|
|
51
|
+
}): SlotlockWebhookSender;
|
|
52
|
+
export interface SlotlockWebhookPassResult {
|
|
53
|
+
/** Changes an endpoint accepted. */
|
|
54
|
+
delivered: number;
|
|
55
|
+
/** Endpoints whose next change failed, to be tried again later. */
|
|
56
|
+
failed: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One delivery pass for a tenant: claim each endpoint with changes due, send them in order until
|
|
60
|
+
* one fails, and record how far each got. A 2xx is delivered; 410 disables the endpoint; anything
|
|
61
|
+
* else, or no answer, is retried on the schedule. The send happens outside any transaction.
|
|
62
|
+
*/
|
|
63
|
+
export declare function deliverSlotlockWebhooks(params: {
|
|
64
|
+
feed: Pick<SlotlockEventFeed, 'claimWebhookDeliveries' | 'settleWebhookDelivery'>;
|
|
65
|
+
tenantRef: string;
|
|
66
|
+
send: SlotlockWebhookSender;
|
|
67
|
+
batch?: number;
|
|
68
|
+
leaseMs?: number;
|
|
69
|
+
/** After this long (default 30,000 ms) no further change is sent this pass; the rest wait. */
|
|
70
|
+
budgetMs?: number;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
}): Promise<SlotlockWebhookPassResult>;
|
|
73
|
+
export interface SlotlockWebhookScheduler {
|
|
74
|
+
/** Deliver for this tenant too (a server calls it for each tenant it serves). */
|
|
75
|
+
touch(tenantRef: string): void;
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Delivery passes every `intervalMs` (250-60,000, default 2,000) for the tenants `tenants()` lists
|
|
80
|
+
* (the store's webhook_tenants(), say) and those touched, one tenant at a time. A pass that fails
|
|
81
|
+
* is reported and the next one runs as usual.
|
|
82
|
+
*/
|
|
83
|
+
export declare function startSlotlockWebhookDelivery(options: {
|
|
84
|
+
tenants: () => Promise<readonly string[]>;
|
|
85
|
+
/** Tenants delivered at once (1-64, default 8), so one slow tenant's endpoints hold up none other. */
|
|
86
|
+
concurrency?: number;
|
|
87
|
+
deliver: (tenantRef: string) => Promise<SlotlockWebhookPassResult>;
|
|
88
|
+
intervalMs?: number;
|
|
89
|
+
onPass?: (tenantRef: string, result: SlotlockWebhookPassResult) => void;
|
|
90
|
+
onError?: (error: unknown) => void;
|
|
91
|
+
}): SlotlockWebhookScheduler;
|
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
|
+
}
|