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/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('/').some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
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 outcome = await Promise.race([target.fetch(webRequest), handlerTimeout]);
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');