slotlock 0.6.2 → 0.6.3
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 +25 -0
- package/README.md +13 -6
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +13 -0
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/rate-limit.js
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
// t=<seconds>` and `"<policy>";q=<quota>;w=<window seconds>`, or `qu="concurrent-requests"` for a
|
|
12
12
|
// cap on requests at once. Retry-After is never earlier than the end of the effective window (§6).
|
|
13
13
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
-
import { createHmac, randomBytes } from 'node:crypto';
|
|
14
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
15
15
|
import { isIP } from 'node:net';
|
|
16
16
|
import { SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SlotlockAgentOperationError, slotlockAgentOperationScope, } from './agent-server.js';
|
|
17
17
|
import { slotlockBearerCredential } from './api-keys.js';
|
|
18
18
|
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
19
|
+
import { SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, } from './config.js';
|
|
19
20
|
import { SLOTLOCK_APPLICATION_REST_ROUTES, matchSlotlockRestRoute } from './rest-api.js';
|
|
20
21
|
/** Keys one policy remembers; past this, the least recently used tenth is dropped at once. */
|
|
21
22
|
const MAX_BUCKET_KEYS = 100_000;
|
|
@@ -88,9 +89,23 @@ export function slotlockClientNetwork(key) {
|
|
|
88
89
|
const prefix = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):[0-9a-f]{1,4}::\/64$/.exec(key);
|
|
89
90
|
return prefix ? `${prefix[1]}::/48` : key;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
const sha256 = (value) => createHash('sha256').update(value, 'utf8').digest();
|
|
93
|
+
/**
|
|
94
|
+
* Whether the request carries the proxy's secret. Compared as SHA-256 digests in constant time, so
|
|
95
|
+
* neither the time taken nor a length tells how much of a guess matched. A repeated field arrives
|
|
96
|
+
* joined with a comma and so never matches.
|
|
97
|
+
*/
|
|
98
|
+
function provenByProxy(request, secretDigest) {
|
|
99
|
+
const presented = request.headers.get(SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD);
|
|
100
|
+
return presented !== null && timingSafeEqual(sha256(presented), secretDigest);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The client a request is counted as: the trusted field's last address, else the connection's.
|
|
104
|
+
* With a proxy secret, the field is believed only beside the proof.
|
|
105
|
+
*/
|
|
106
|
+
function clientOf(request, connection, trustedField, secretDigest) {
|
|
107
|
+
const believed = secretDigest === undefined || provenByProxy(request, secretDigest);
|
|
108
|
+
if (trustedField !== undefined && believed) {
|
|
94
109
|
// A proxy appends to a list field (X-Forwarded-For), so the last entry is the one it wrote.
|
|
95
110
|
const claimed = request.headers.get(trustedField)?.split(',').at(-1);
|
|
96
111
|
const key = claimed === undefined ? null : slotlockClientKey(claimed);
|
|
@@ -169,7 +184,7 @@ function concurrencyFields(name, cap) {
|
|
|
169
184
|
};
|
|
170
185
|
}
|
|
171
186
|
/** Token buckets of one policy, one per key, refilled lazily. */
|
|
172
|
-
function bucketsFor(rate
|
|
187
|
+
function bucketsFor(rate) {
|
|
173
188
|
const buckets = new Map();
|
|
174
189
|
const refill = (bucket, at) => {
|
|
175
190
|
bucket.tokens = Math.min(rate.burst, bucket.tokens + ((at - bucket.at) / 1_000) * rate.perSecond);
|
|
@@ -211,7 +226,7 @@ export function createSlotlockRateLimiter(rate, now = () => performance.now()) {
|
|
|
211
226
|
if (!(rate.perSecond > 0) || !(rate.burst >= 1)) {
|
|
212
227
|
throw new Error('Slotlock rate limiter needs a positive rate and a burst of at least 1');
|
|
213
228
|
}
|
|
214
|
-
const buckets = bucketsFor(rate
|
|
229
|
+
const buckets = bucketsFor(rate);
|
|
215
230
|
return {
|
|
216
231
|
take(key) {
|
|
217
232
|
const bucket = buckets.get(key, now());
|
|
@@ -416,11 +431,12 @@ export function createSlotlockRequestLimits(options) {
|
|
|
416
431
|
const policies = new Map();
|
|
417
432
|
for (const [name, rate] of Object.entries(rates)) {
|
|
418
433
|
if (rate !== null)
|
|
419
|
-
policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate
|
|
434
|
+
policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate) });
|
|
420
435
|
}
|
|
421
436
|
const states = new AsyncLocalStorage();
|
|
422
437
|
const looked = new WeakMap();
|
|
423
438
|
const addresses = new WeakMap();
|
|
439
|
+
const proxySecretDigest = options.trustedProxySecret === undefined ? undefined : sha256(options.trustedProxySecret);
|
|
424
440
|
const gates = {
|
|
425
441
|
series: {
|
|
426
442
|
gate: seriesGate(caps.tenant_series_writes, caps.series_writes),
|
|
@@ -665,7 +681,7 @@ export function createSlotlockRequestLimits(options) {
|
|
|
665
681
|
const limited = {
|
|
666
682
|
async fetch(request, connection) {
|
|
667
683
|
const route = routeOf(request);
|
|
668
|
-
const address = clientOf(request, connection, options.trustedProxyHeader);
|
|
684
|
+
const address = clientOf(request, connection, options.trustedProxyHeader, proxySecretDigest);
|
|
669
685
|
addresses.set(request, address);
|
|
670
686
|
if (options.trustedProxyHeader === undefined && !fieldIgnored) {
|
|
671
687
|
const field = PROXY_CLIENT_FIELDS.find((name) => request.headers.has(name));
|
package/rest-api.d.ts
CHANGED
|
@@ -6,9 +6,8 @@ export interface SlotlockRestRoute<Operation extends string = SlotlockAgentOpera
|
|
|
6
6
|
path: string;
|
|
7
7
|
operation: Operation;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* stale revision). The OpenAPI document declares them, so a generated client types them.
|
|
9
|
+
* Store refusals the route can answer beyond the common ones: 404 (missing or another tenant's)
|
|
10
|
+
* and 409 (a conflict with what is stored). Declared in OpenAPI so generated clients type them.
|
|
12
11
|
*/
|
|
13
12
|
refusals?: readonly (404 | 409)[];
|
|
14
13
|
}
|
|
@@ -20,15 +19,15 @@ export declare const SLOTLOCK_PROBLEM_DOCS = "https://slotlock.pylota.io/docs/ap
|
|
|
20
19
|
/** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
|
|
21
20
|
export declare function slotlockProblemType(code: string): string;
|
|
22
21
|
/**
|
|
23
|
-
* A REST refusal as RFC 9457 problem details
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* A REST refusal as RFC 9457 problem details: `type` and `title` name the problem, `code` is
|
|
23
|
+
* Slotlock's code, and `error.code` repeats it for older clients. `extensions` add context, such as
|
|
24
|
+
* the interval an overlap refused.
|
|
26
25
|
*/
|
|
27
26
|
export declare function slotlockProblem(status: number, code: string, extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
28
27
|
/**
|
|
29
|
-
* The Idempotency-Key header's value: a Structured Field String (RFC 9651,
|
|
30
|
-
* draft-ietf-httpapi-idempotency-key-header-08
|
|
31
|
-
*
|
|
28
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, per
|
|
29
|
+
* draft-ietf-httpapi-idempotency-key-header-08), or a bare token as older clients send. `null` for
|
|
30
|
+
* a quoted string that does not parse.
|
|
32
31
|
*/
|
|
33
32
|
export declare function parseIdempotencyKeyHeader(value: string): string | null;
|
|
34
33
|
/** Where an operation's arguments come from. */
|
|
@@ -44,15 +43,15 @@ export type SlotlockRestMatch<Operation extends string = SlotlockAgentOperation>
|
|
|
44
43
|
invalid: true;
|
|
45
44
|
} | null;
|
|
46
45
|
/**
|
|
47
|
-
* The route for a method and path, the methods the path does have, or nothing.
|
|
48
|
-
*
|
|
46
|
+
* The route for a method and path, the methods the path does have, or nothing. First fit wins; a
|
|
47
|
+
* test checks no two routes fit the same path and method.
|
|
49
48
|
*/
|
|
50
49
|
export declare function matchSlotlockRestRoute<Operation extends string = SlotlockAgentOperation>(method: string, path: string, routes?: readonly SlotlockRestRoute<Operation>[]): SlotlockRestMatch<Operation>;
|
|
51
50
|
type JsonSchema = Record<string, unknown>;
|
|
52
51
|
/**
|
|
53
|
-
* An operation's input from a query string:
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* An operation's input from a query string: array parameters repeat (OpenAPI's default form style),
|
|
53
|
+
* others appear once. Unknown or repeated parameters pass through so the strict schema refuses
|
|
54
|
+
* them.
|
|
56
55
|
*/
|
|
57
56
|
export declare function restQueryInput(search: URLSearchParams, inputSchema: JsonSchema): Record<string, unknown>;
|
|
58
57
|
/** One operation, as the OpenAPI document needs it. */
|
package/rest-api.js
CHANGED
|
@@ -86,9 +86,9 @@ const STATUS_TITLES = Object.freeze({
|
|
|
86
86
|
503: 'Service Unavailable',
|
|
87
87
|
});
|
|
88
88
|
/**
|
|
89
|
-
* A REST refusal as RFC 9457 problem details
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* A REST refusal as RFC 9457 problem details: `type` and `title` name the problem, `code` is
|
|
90
|
+
* Slotlock's code, and `error.code` repeats it for older clients. `extensions` add context, such as
|
|
91
|
+
* the interval an overlap refused.
|
|
92
92
|
*/
|
|
93
93
|
export function slotlockProblem(status, code, extensions = {}) {
|
|
94
94
|
return {
|
|
@@ -101,9 +101,9 @@ export function slotlockProblem(status, code, extensions = {}) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
/**
|
|
104
|
-
* The Idempotency-Key header's value: a Structured Field String (RFC 9651,
|
|
105
|
-
* draft-ietf-httpapi-idempotency-key-header-08
|
|
106
|
-
*
|
|
104
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, per
|
|
105
|
+
* draft-ietf-httpapi-idempotency-key-header-08), or a bare token as older clients send. `null` for
|
|
106
|
+
* a quoted string that does not parse.
|
|
107
107
|
*/
|
|
108
108
|
export function parseIdempotencyKeyHeader(value) {
|
|
109
109
|
const trimmed = value.trim();
|
|
@@ -158,8 +158,8 @@ function matchPath(pattern, segments) {
|
|
|
158
158
|
return params;
|
|
159
159
|
}
|
|
160
160
|
/**
|
|
161
|
-
* The route for a method and path, the methods the path does have, or nothing.
|
|
162
|
-
*
|
|
161
|
+
* The route for a method and path, the methods the path does have, or nothing. First fit wins; a
|
|
162
|
+
* test checks no two routes fit the same path and method.
|
|
163
163
|
*/
|
|
164
164
|
export function matchSlotlockRestRoute(method, path, routes = SLOTLOCK_REST_ROUTES) {
|
|
165
165
|
const segments = path.split('/');
|
|
@@ -209,9 +209,9 @@ function queryValue(value, schema) {
|
|
|
209
209
|
return value;
|
|
210
210
|
}
|
|
211
211
|
/**
|
|
212
|
-
* An operation's input from a query string:
|
|
213
|
-
*
|
|
214
|
-
*
|
|
212
|
+
* An operation's input from a query string: array parameters repeat (OpenAPI's default form style),
|
|
213
|
+
* others appear once. Unknown or repeated parameters pass through so the strict schema refuses
|
|
214
|
+
* them.
|
|
215
215
|
*/
|
|
216
216
|
export function restQueryInput(search, inputSchema) {
|
|
217
217
|
const input = {};
|
|
@@ -338,7 +338,6 @@ export function slotlockOpenApiDocument(args) {
|
|
|
338
338
|
'403': ERROR_RESPONSE,
|
|
339
339
|
'404': ERROR_RESPONSE,
|
|
340
340
|
...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
|
|
341
|
-
// A change cursor past the feed's retention.
|
|
342
341
|
...(route.operation === 'slotlock_list_changes' ? { '410': ERROR_RESPONSE } : {}),
|
|
343
342
|
'429': ERROR_RESPONSE,
|
|
344
343
|
default: ERROR_RESPONSE,
|
package/rules.d.ts
CHANGED
|
@@ -13,15 +13,14 @@ export declare function invalidWindowError(message?: string): Error & {
|
|
|
13
13
|
code: 'invalid_window';
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
|
-
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS
|
|
17
|
-
* is synchronous
|
|
16
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS: expansion
|
|
17
|
+
* is synchronous and grows with the window.
|
|
18
18
|
*/
|
|
19
19
|
export declare function assertBoundedSearchWindow(window: Interval): void;
|
|
20
20
|
/**
|
|
21
|
-
* Expand availability rules to
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
21
|
+
* Expand availability rules to merged, sorted UTC windows clipped to `searchWindow`. Unsupported or
|
|
22
|
+
* unparseable rules contribute nothing rather than throwing: one bad rule must neither blank the
|
|
23
|
+
* calendar nor manufacture availability. Validate at write time; this is the fail-closed backstop.
|
|
24
|
+
* A window over SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
26
25
|
*/
|
|
27
26
|
export declare function expandRules(rules: WeeklyAvailabilityRule[], searchWindow: Interval, timezone?: string): Interval[];
|
package/rules.js
CHANGED
|
@@ -21,15 +21,14 @@ export function invalidWindowError(message = 'Slotlock window must contain two f
|
|
|
21
21
|
return Object.assign(new Error(message), { code: 'invalid_window' });
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS
|
|
25
|
-
* is synchronous
|
|
24
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS: expansion
|
|
25
|
+
* is synchronous and grows with the window.
|
|
26
26
|
*/
|
|
27
27
|
export function assertBoundedSearchWindow(window) {
|
|
28
28
|
if (window.end.getTime() - window.start.getTime() > MAX_SEARCH_WINDOW_MS) {
|
|
29
29
|
throw invalidWindowError(`Slotlock searches a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days at once`);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
/** UTC midnight of the day containing `d`. */
|
|
33
32
|
function utcDayFloor(d) {
|
|
34
33
|
return new Date(Math.floor(d.getTime() / DAY_MS) * DAY_MS);
|
|
35
34
|
}
|
|
@@ -38,15 +37,15 @@ const WEEKDAY = /^(?:MO|TU|WE|TH|FR|SA|SU)$/;
|
|
|
38
37
|
const ABSOLUTE_UNTIL = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
|
|
39
38
|
/**
|
|
40
39
|
* Whether `until` is an absolute UNTIL naming a real instant. rrule reads it with Date.UTC, which
|
|
41
|
-
* carries
|
|
42
|
-
*
|
|
40
|
+
* carries overflow into the next unit (31 February becomes 3 March). A leap second (60) is real
|
|
41
|
+
* (RFC 5545 3.3.12).
|
|
43
42
|
*/
|
|
44
43
|
function isRealUntil(until) {
|
|
45
44
|
const match = ABSOLUTE_UNTIL.exec(until);
|
|
46
45
|
if (!match)
|
|
47
46
|
return false;
|
|
48
47
|
const [, year, month, day, hour, minute, second] = match;
|
|
49
|
-
// Set without overflow checks
|
|
48
|
+
// Set without overflow checks: the date reads back differently when any part overflowed.
|
|
50
49
|
const date = new Date(0);
|
|
51
50
|
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
|
|
52
51
|
return (date.toISOString().startsWith(`${year}-${month}-${day}T`) &&
|
|
@@ -55,10 +54,10 @@ function isRealUntil(until) {
|
|
|
55
54
|
Number(second) <= 60);
|
|
56
55
|
}
|
|
57
56
|
/**
|
|
58
|
-
* Whether `rrule` is
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
57
|
+
* Whether `rrule` is in SPEC.md's subset: `FREQ=WEEKLY`, a `BYDAY` of plain weekdays, optionally
|
|
58
|
+
* `INTERVAL=1` and an absolute `UNTIL`, each once, in any order. Checked before rrule parses it:
|
|
59
|
+
* rrule honours a DTSTART's TZID, takes the first of two BYDAYs, and lets BYHOUR/BYMINUTE/BYSECOND
|
|
60
|
+
* multiply occurrences.
|
|
62
61
|
*/
|
|
63
62
|
function isWeeklySubset(rrule) {
|
|
64
63
|
const parts = new Map();
|
|
@@ -76,14 +75,12 @@ function isWeeklySubset(rrule) {
|
|
|
76
75
|
(until === undefined || isRealUntil(until)));
|
|
77
76
|
}
|
|
78
77
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* - freq must be WEEKLY
|
|
82
|
-
*
|
|
83
|
-
* - COUNT
|
|
84
|
-
*
|
|
85
|
-
* - INTERVAL>1 parity depends on the anchor — re-anchoring makes biweekly availability flip with
|
|
86
|
-
* the query date. (UNTIL is absolute and expands correctly, so it stays allowed.)
|
|
78
|
+
* Only the weekly subset that can be answered deterministically; anything else fails closed to not
|
|
79
|
+
* bookable:
|
|
80
|
+
* - freq must be WEEKLY: a freq-less string otherwise becomes a YEARLY rule anchored at the query
|
|
81
|
+
* date;
|
|
82
|
+
* - COUNT needs an authored DTSTART: re-anchoring restarts it on every query;
|
|
83
|
+
* - INTERVAL>1 parity depends on the anchor. UNTIL is absolute and stays allowed.
|
|
87
84
|
*/
|
|
88
85
|
function isSupportedRule(parsed) {
|
|
89
86
|
if (parsed.freq !== RRule.WEEKLY)
|
|
@@ -92,8 +89,8 @@ function isSupportedRule(parsed) {
|
|
|
92
89
|
return false;
|
|
93
90
|
if (parsed.interval !== undefined && parsed.interval !== 1)
|
|
94
91
|
return false;
|
|
95
|
-
// BYDAY-less WEEKLY
|
|
96
|
-
//
|
|
92
|
+
// BYDAY-less WEEKLY would take its weekday from the re-anchored DTSTART, varying with the query
|
|
93
|
+
// date.
|
|
97
94
|
const byweekday = parsed.byweekday;
|
|
98
95
|
if (byweekday === undefined || byweekday === null)
|
|
99
96
|
return false;
|
|
@@ -102,16 +99,14 @@ function isSupportedRule(parsed) {
|
|
|
102
99
|
return true;
|
|
103
100
|
}
|
|
104
101
|
/**
|
|
105
|
-
* Expand availability rules to
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
102
|
+
* Expand availability rules to merged, sorted UTC windows clipped to `searchWindow`. Unsupported or
|
|
103
|
+
* unparseable rules contribute nothing rather than throwing: one bad rule must neither blank the
|
|
104
|
+
* calendar nor manufacture availability. Validate at write time; this is the fail-closed backstop.
|
|
105
|
+
* A window over SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
110
106
|
*/
|
|
111
107
|
export function expandRules(rules, searchWindow, timezone) {
|
|
112
108
|
assertBoundedSearchWindow(searchWindow);
|
|
113
|
-
// Validate
|
|
114
|
-
// would manufacture availability shifted by the zone offset. 'UTC'/omitted = spike path.
|
|
109
|
+
// Validate once: a typo'd zone must never silently fall back to UTC and shift availability.
|
|
115
110
|
const converter = timezone !== undefined ? createTimeZoneConverter(timezone) : null;
|
|
116
111
|
const windows = [];
|
|
117
112
|
for (const rule of rules) {
|
|
@@ -125,9 +120,8 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
125
120
|
}
|
|
126
121
|
let rrule;
|
|
127
122
|
let absoluteUntil = null;
|
|
128
|
-
// A window can
|
|
129
|
-
//
|
|
130
|
-
// windows like "Mon 00:00 + 3 days" are legal; a fixed one-day margin silently lost them).
|
|
123
|
+
// A window can start days before the search window and still reach into it: scan back as far as
|
|
124
|
+
// the longest window this rule can produce.
|
|
131
125
|
const backScanDays = Math.ceil((rule.startMinutes + rule.durationMinutes) / (24 * 60));
|
|
132
126
|
const scanStart = utcDayFloor(new Date(searchWindow.start.getTime() - backScanDays * DAY_MS));
|
|
133
127
|
if (typeof rule.rrule !== 'string' || !isWeeklySubset(rule.rrule))
|
|
@@ -136,10 +130,8 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
136
130
|
const parsed = RRule.parseString(rule.rrule);
|
|
137
131
|
if (!isSupportedRule(parsed))
|
|
138
132
|
continue;
|
|
139
|
-
// rrule's stamps are days, not window starts
|
|
140
|
-
//
|
|
141
|
-
// and under a zone east of UTC drop one that starts before it. Each window's real start is
|
|
142
|
-
// compared with UNTIL instead (below), with or without a zone, so the two paths agree.
|
|
133
|
+
// rrule's stamps are days, not window starts, so its UNTIL handling is wrong at both ends;
|
|
134
|
+
// each window's real start is compared with UNTIL below instead.
|
|
143
135
|
if (parsed.until) {
|
|
144
136
|
absoluteUntil = parsed.until;
|
|
145
137
|
parsed.until = null;
|
|
@@ -150,19 +142,16 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
150
142
|
catch {
|
|
151
143
|
continue;
|
|
152
144
|
}
|
|
153
|
-
// Under a zone
|
|
154
|
-
//
|
|
155
|
-
// and let clipping discard the overshoot.
|
|
145
|
+
// Under a zone a local day whose UTC stamp is past the window end can still start before it
|
|
146
|
+
// (east zones are up to 14h ahead): scan one day over and let clipping discard the overshoot.
|
|
156
147
|
const scanEnd = converter ? new Date(searchWindow.end.getTime() + DAY_MS) : searchWindow.end;
|
|
157
148
|
const days = rrule.between(scanStart, scanEnd, true);
|
|
158
149
|
for (const day of days) {
|
|
159
150
|
let start;
|
|
160
151
|
let end;
|
|
161
152
|
if (converter) {
|
|
162
|
-
// The stamp's Y/M/D is the
|
|
163
|
-
//
|
|
164
|
-
// reads a time, [02:30, 03:30) gave nothing although 03:00-03:30 exists, and [02:00,
|
|
165
|
-
// 02:30) gave 03:00-03:30, which it never names.
|
|
153
|
+
// The stamp's Y/M/D is the local date; start and end are wall-clock. An edge the clocks
|
|
154
|
+
// skip stands where the skipped hour ends, so the window holds only wall times that exist.
|
|
166
155
|
const wallDay = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
167
156
|
start = converter.wallFrameToUtc(wallDay + rule.startMinutes * MINUTE_MS, { gap: 'end' });
|
|
168
157
|
end = converter.wallFrameToUtc(wallDay + (rule.startMinutes + rule.durationMinutes) * MINUTE_MS, { gap: 'end' });
|
|
@@ -176,7 +165,6 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
176
165
|
}
|
|
177
166
|
if (absoluteUntil && start.getTime() > absoluteUntil.getTime())
|
|
178
167
|
continue;
|
|
179
|
-
// Clip to the search window; drop windows that miss it entirely.
|
|
180
168
|
const clippedStart = start.getTime() < searchWindow.start.getTime() ? searchWindow.start : start;
|
|
181
169
|
const clippedEnd = end.getTime() > searchWindow.end.getTime() ? searchWindow.end : end;
|
|
182
170
|
if (clippedStart.getTime() < clippedEnd.getTime()) {
|
package/self-host.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type SlotlockSql, type SlotlockStore } from './store.js';
|
|
|
5
5
|
import type { WeeklyAvailabilityRule } from './types.js';
|
|
6
6
|
export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig };
|
|
7
7
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
8
|
-
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
|
|
8
|
+
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_TRUSTED_PROXY_SECRET", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
|
|
9
9
|
export interface SlotlockDatabaseConfig {
|
|
10
10
|
/** The role `serve` and `resource` connect as. */
|
|
11
11
|
databaseUrl: string;
|
|
@@ -49,6 +49,11 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
49
49
|
* keys may act for, whether it serves the dashboard or another server (a Worker) does.
|
|
50
50
|
*/
|
|
51
51
|
organizations?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* GitHub user ids (SLOTLOCK_DASHBOARD_USERS), or `'*'`, whose `github:<id>` tenants this server's
|
|
54
|
+
* keys may act for, whether it serves the dashboard or another server (a Worker) does.
|
|
55
|
+
*/
|
|
56
|
+
allowedUsers?: '*' | string[];
|
|
52
57
|
/**
|
|
53
58
|
* Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
|
|
54
59
|
* (SLOTLOCK_REST_API: on unless off).
|
|
@@ -62,6 +67,8 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
62
67
|
limits: SlotlockLimits;
|
|
63
68
|
/** The lower-case request field a trusted proxy sets to the client address; else the socket's. */
|
|
64
69
|
trustedProxyHeader?: string;
|
|
70
|
+
/** The value that must accompany trustedProxyHeader in x-slotlock-proxy-proof; else the socket's. */
|
|
71
|
+
trustedProxySecret?: string;
|
|
65
72
|
}
|
|
66
73
|
export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
|
|
67
74
|
export declare function readSlotlockServeConfig(env: SlotlockEnv): SlotlockServeConfig;
|
|
@@ -110,6 +117,13 @@ export interface SlotlockRunningServer {
|
|
|
110
117
|
/** End open subscriptions, drain in-flight requests, then close the database pool. */
|
|
111
118
|
close(): Promise<SlotlockNodeServerCloseResult>;
|
|
112
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
|
|
122
|
+
* `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
|
|
123
|
+
* `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
|
|
124
|
+
* SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
|
|
125
|
+
*/
|
|
126
|
+
export declare function slotlockServeAllowsTenant(config: Pick<SlotlockServeConfig, 'organizations' | 'allowedUsers'>, tenantRef: string): boolean;
|
|
113
127
|
/**
|
|
114
128
|
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
115
129
|
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
@@ -124,7 +138,7 @@ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: S
|
|
|
124
138
|
}): SlotlockStore;
|
|
125
139
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
126
140
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
127
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains
|
|
141
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
128
142
|
/**
|
|
129
143
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
130
144
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|