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.
- package/CHANGELOG.md +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- 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/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/metrics.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Call duration buckets, in seconds. */
|
|
2
|
+
const BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
3
|
+
const REFUSAL_REASONS = [
|
|
4
|
+
'auth_refused',
|
|
5
|
+
'rate_limited',
|
|
6
|
+
'lookup_overflow',
|
|
7
|
+
'series_writes_busy',
|
|
8
|
+
'maintenance_busy',
|
|
9
|
+
'request_too_large',
|
|
10
|
+
];
|
|
11
|
+
const MAINTENANCE_COUNTS = [
|
|
12
|
+
'extended',
|
|
13
|
+
'conflicts',
|
|
14
|
+
'refused',
|
|
15
|
+
'pruned',
|
|
16
|
+
'zoneShifts',
|
|
17
|
+
'staleSources',
|
|
18
|
+
'noShows',
|
|
19
|
+
'waitlistOffers',
|
|
20
|
+
];
|
|
21
|
+
/** A label value: an operation, protocol or outcome code. Anything else is counted as `other`. */
|
|
22
|
+
const LABEL = /^[a-z0-9_.]{1,64}$/;
|
|
23
|
+
const label = (value) => (LABEL.test(value) ? value : 'other');
|
|
24
|
+
const key = (labels) => JSON.stringify(Object.entries(labels));
|
|
25
|
+
function formatLabels(labels) {
|
|
26
|
+
const entries = Object.entries(labels);
|
|
27
|
+
if (entries.length === 0)
|
|
28
|
+
return '';
|
|
29
|
+
// Label values are from LABEL's set or the version strings below: escaped anyway.
|
|
30
|
+
const escaped = entries.map(([name, value]) => `${name}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`);
|
|
31
|
+
return `{${escaped.join(',')}}`;
|
|
32
|
+
}
|
|
33
|
+
class Counter {
|
|
34
|
+
name;
|
|
35
|
+
help;
|
|
36
|
+
values = new Map();
|
|
37
|
+
constructor(name, help) {
|
|
38
|
+
this.name = name;
|
|
39
|
+
this.help = help;
|
|
40
|
+
}
|
|
41
|
+
add(labels, amount = 1) {
|
|
42
|
+
const id = key(labels);
|
|
43
|
+
const current = this.values.get(id) ?? { labels, value: 0 };
|
|
44
|
+
current.value += amount;
|
|
45
|
+
this.values.set(id, current);
|
|
46
|
+
}
|
|
47
|
+
render(type = 'counter') {
|
|
48
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} ${type}`];
|
|
49
|
+
for (const { labels, value } of this.values.values()) {
|
|
50
|
+
lines.push(`${this.name}${formatLabels(labels)} ${value}`);
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
class Histogram {
|
|
56
|
+
name;
|
|
57
|
+
help;
|
|
58
|
+
series = new Map();
|
|
59
|
+
constructor(name, help) {
|
|
60
|
+
this.name = name;
|
|
61
|
+
this.help = help;
|
|
62
|
+
}
|
|
63
|
+
observe(labels, seconds) {
|
|
64
|
+
const id = key(labels);
|
|
65
|
+
const current = this.series.get(id) ?? {
|
|
66
|
+
labels,
|
|
67
|
+
buckets: BUCKETS.map(() => 0),
|
|
68
|
+
sum: 0,
|
|
69
|
+
count: 0,
|
|
70
|
+
};
|
|
71
|
+
BUCKETS.forEach((bound, index) => {
|
|
72
|
+
if (seconds <= bound)
|
|
73
|
+
current.buckets[index] = (current.buckets[index] ?? 0) + 1;
|
|
74
|
+
});
|
|
75
|
+
current.sum += seconds;
|
|
76
|
+
current.count += 1;
|
|
77
|
+
this.series.set(id, current);
|
|
78
|
+
}
|
|
79
|
+
render() {
|
|
80
|
+
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} histogram`];
|
|
81
|
+
for (const { labels, buckets, sum, count } of this.series.values()) {
|
|
82
|
+
BUCKETS.forEach((bound, index) => {
|
|
83
|
+
lines.push(`${this.name}_bucket${formatLabels({ ...labels, le: String(bound) })} ${buckets[index]}`);
|
|
84
|
+
});
|
|
85
|
+
lines.push(`${this.name}_bucket${formatLabels({ ...labels, le: '+Inf' })} ${count}`);
|
|
86
|
+
lines.push(`${this.name}_sum${formatLabels(labels)} ${sum}`);
|
|
87
|
+
lines.push(`${this.name}_count${formatLabels(labels)} ${count}`);
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Counters for one server process, reset when it restarts (`slotlock_start_time_seconds`). */
|
|
93
|
+
export function createSlotlockMetrics(info) {
|
|
94
|
+
const started = (info.now ?? Date.now)() / 1000;
|
|
95
|
+
const calls = new Counter('slotlock_calls_total', 'Agent and REST calls by protocol, operation and outcome (ok, a refusal code, or an HTTP status).');
|
|
96
|
+
const duration = new Histogram('slotlock_call_duration_seconds', 'How long agent and REST calls took, by protocol and operation.');
|
|
97
|
+
const confirmations = new Counter('slotlock_confirmations_total', 'Write confirmations asked of a person, by operation and outcome.');
|
|
98
|
+
const subscriptions = new Counter('slotlock_subscriptions_total', 'MCP subscriptions opened and closed, by outcome and the reason one closed.');
|
|
99
|
+
let open = 0;
|
|
100
|
+
const refused = new Counter('slotlock_refusals_total', 'Requests refused before they ran, by reason.');
|
|
101
|
+
const passes = new Counter('slotlock_maintenance_passes_total', 'Maintenance passes, by whether one was capped.');
|
|
102
|
+
const upkeep = new Counter('slotlock_maintenance_items_total', 'What maintenance did, by kind: series extended, holds pruned, no-shows released, waitlist offers made, and so on.');
|
|
103
|
+
const deliveries = new Counter('slotlock_webhook_deliveries_total', 'Webhook changes delivered, and deliveries that failed and will be retried.');
|
|
104
|
+
return {
|
|
105
|
+
event(event) {
|
|
106
|
+
if (event.type === 'call' || event.type === 'rest') {
|
|
107
|
+
const protocol = event.type === 'rest' ? 'rest' : event.protocol;
|
|
108
|
+
const operation = label(event.operation);
|
|
109
|
+
const outcome = event.type === 'rest' ? String(event.status) : label(event.outcome);
|
|
110
|
+
calls.add({ protocol, operation, outcome });
|
|
111
|
+
duration.observe({ protocol, operation }, Math.max(0, event.durationMs) / 1000);
|
|
112
|
+
}
|
|
113
|
+
else if (event.type === 'confirmation') {
|
|
114
|
+
confirmations.add({ operation: label(event.operation), outcome: event.outcome });
|
|
115
|
+
}
|
|
116
|
+
else if (event.type === 'subscription') {
|
|
117
|
+
subscriptions.add({ outcome: event.outcome, reason: event.reason ?? 'none' });
|
|
118
|
+
open = Math.max(0, open + (event.outcome === 'opened' ? 1 : -1));
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
refusals(counts) {
|
|
122
|
+
for (const reason of REFUSAL_REASONS) {
|
|
123
|
+
const amount = counts[reason];
|
|
124
|
+
if (amount > 0)
|
|
125
|
+
refused.add({ reason }, amount);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
maintenance(result) {
|
|
129
|
+
passes.add({ capped: String(result.capped) });
|
|
130
|
+
for (const kind of MAINTENANCE_COUNTS) {
|
|
131
|
+
if (result[kind] > 0)
|
|
132
|
+
upkeep.add({ kind }, result[kind]);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
refusedScrape() {
|
|
136
|
+
refused.add({ reason: 'metrics_auth_refused' });
|
|
137
|
+
},
|
|
138
|
+
webhooks(result) {
|
|
139
|
+
if (result.delivered > 0)
|
|
140
|
+
deliveries.add({ result: 'delivered' }, result.delivered);
|
|
141
|
+
if (result.failed > 0)
|
|
142
|
+
deliveries.add({ result: 'failed' }, result.failed);
|
|
143
|
+
},
|
|
144
|
+
render() {
|
|
145
|
+
const lines = [
|
|
146
|
+
'# HELP slotlock_build_info The Slotlock version and tz database the server runs.',
|
|
147
|
+
'# TYPE slotlock_build_info gauge',
|
|
148
|
+
`slotlock_build_info${formatLabels({ version: info.version, tzdata: info.tzdata ?? 'unknown' })} 1`,
|
|
149
|
+
'# HELP slotlock_start_time_seconds When the server started, in seconds since the epoch.',
|
|
150
|
+
'# TYPE slotlock_start_time_seconds gauge',
|
|
151
|
+
`slotlock_start_time_seconds ${started}`,
|
|
152
|
+
'# HELP slotlock_subscriptions_open MCP subscriptions open now.',
|
|
153
|
+
'# TYPE slotlock_subscriptions_open gauge',
|
|
154
|
+
`slotlock_subscriptions_open ${open}`,
|
|
155
|
+
...calls.render(),
|
|
156
|
+
...duration.render(),
|
|
157
|
+
...confirmations.render(),
|
|
158
|
+
...subscriptions.render(),
|
|
159
|
+
...refused.render(),
|
|
160
|
+
...passes.render(),
|
|
161
|
+
...upkeep.render(),
|
|
162
|
+
...deliveries.render(),
|
|
163
|
+
];
|
|
164
|
+
return `${lines.join('\n')}\n`;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/node-server.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/// <reference types="node" preserve="true" />
|
|
2
2
|
import { type Server } from 'node:http';
|
|
3
3
|
export type SlotlockNodeServerErrorPhase = 'client' | 'listen' | 'request' | 'server' | 'shutdown';
|
|
4
|
+
/** What the listener knows about a request's connection, beside the Request itself. */
|
|
5
|
+
export interface SlotlockNodeServerConnection {
|
|
6
|
+
/**
|
|
7
|
+
* The peer's address as the socket reports it (an IPv4 client of a dual-stack listener reads
|
|
8
|
+
* `::ffff:a.b.c.d`). Behind a proxy it is the proxy's; never taken from a request field.
|
|
9
|
+
*/
|
|
10
|
+
remoteAddress?: string;
|
|
11
|
+
}
|
|
4
12
|
/** The narrow Fetch-compatible boundary returned by `createSlotlockAgentServer`. */
|
|
5
13
|
export interface SlotlockNodeServerFetchTarget {
|
|
6
|
-
fetch(request: Request): Promise<Response>;
|
|
14
|
+
fetch(request: Request, connection?: SlotlockNodeServerConnection): Promise<Response>;
|
|
7
15
|
/**
|
|
8
16
|
* Called once when `close()` begins, after the listener stops accepting connections, to end
|
|
9
17
|
* long-lived responses (MCP subscription streams) so they drain within the grace period.
|
package/node-server.js
CHANGED
|
@@ -137,8 +137,9 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
137
137
|
}
|
|
138
138
|
// URL parsing resolves dot segments, `%2E` spellings included: `/v1/resources/%2E%2E/availability`
|
|
139
139
|
// would reach the target as `/v1/availability`, a path parameter of `..` naming another route.
|
|
140
|
+
// It also takes a backslash for a slash, so a segment ends at either.
|
|
140
141
|
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
141
|
-
if (path.split(
|
|
142
|
+
if (path.split(/[\\/]/).some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
142
143
|
throw new Error('invalid_request_target');
|
|
143
144
|
}
|
|
144
145
|
const url = new URL(target, requestOrigin);
|
|
@@ -281,7 +282,11 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
281
282
|
timeout.unref();
|
|
282
283
|
});
|
|
283
284
|
try {
|
|
284
|
-
const
|
|
285
|
+
const connection = {};
|
|
286
|
+
if (request.socket.remoteAddress !== undefined) {
|
|
287
|
+
connection.remoteAddress = request.socket.remoteAddress;
|
|
288
|
+
}
|
|
289
|
+
const outcome = await Promise.race([target.fetch(webRequest, connection), handlerTimeout]);
|
|
285
290
|
if (outcome === HANDLER_TIMEOUT) {
|
|
286
291
|
controller.abort(abortReason('handler_timeout'));
|
|
287
292
|
safeReport(config.onError, new Error('handler_timeout'), 'request');
|