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/assistant.d.ts
CHANGED
|
@@ -4,11 +4,10 @@ export type SlotlockAssistantProvider = 'anthropic' | 'openai';
|
|
|
4
4
|
/** What the assistant does with each agent operation: run it, propose it, or never offer it. */
|
|
5
5
|
export type SlotlockAssistantToolUse = 'read' | 'propose' | 'excluded';
|
|
6
6
|
/**
|
|
7
|
-
* The tools the model is offered, by agent operation. A record over every operation, so
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* offer them.
|
|
7
|
+
* The tools the model is offered, by agent operation. A record over every operation, so a new one
|
|
8
|
+
* does not compile until placed here. Reads run when called; writes are only proposed and run on
|
|
9
|
+
* approval. API keys, webhooks, booking rules, pools, erasure and coverage are application
|
|
10
|
+
* operations, never agent ones, so none can be offered.
|
|
12
11
|
*/
|
|
13
12
|
export declare const SLOTLOCK_ASSISTANT_TOOL_USE: Readonly<Record<SlotlockAgentOperation, SlotlockAssistantToolUse>>;
|
|
14
13
|
/** Model calls one message may make before the assistant stops and says so. */
|
|
@@ -16,20 +15,15 @@ export declare const SLOTLOCK_ASSISTANT_MAX_STEPS = 8;
|
|
|
16
15
|
/** Characters of one message from a member. */
|
|
17
16
|
export declare const SLOTLOCK_ASSISTANT_MAX_MESSAGE = 2000;
|
|
18
17
|
/**
|
|
19
|
-
* UTF-8 bytes of the stored conversation as JSON
|
|
20
|
-
*
|
|
21
|
-
* table's 262,144-byte CHECK on `messages::text` whatever the text.
|
|
18
|
+
* UTF-8 bytes of the stored conversation as JSON. jsonb text adds at most one space per `:` or `,`,
|
|
19
|
+
* so this stays under the table's 262,144-byte CHECK on `messages::text`.
|
|
22
20
|
*/
|
|
23
21
|
export declare const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160000;
|
|
24
22
|
/** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
|
|
25
23
|
export declare const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16000;
|
|
26
24
|
/** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
|
|
27
25
|
export declare const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
|
|
28
|
-
/**
|
|
29
|
-
* The Messages API version this sends: the one @anthropic-ai/sdk 1.3.0 (2026-10-23) sends, read
|
|
30
|
-
* from its source on 2026-10-28. Top-level `system`, `tools[].input_schema`, `tool_use` and
|
|
31
|
-
* `tool_result` blocks are as before in it.
|
|
32
|
-
*/
|
|
26
|
+
/** The Messages API version @anthropic-ai/sdk 1.3.0 sends (read from its source 2026-10-28). */
|
|
33
27
|
export declare const SLOTLOCK_ANTHROPIC_VERSION = "2026-10-21";
|
|
34
28
|
/** A model as the assistant calls it, with the key opened. */
|
|
35
29
|
export interface SlotlockAssistantModel {
|
|
@@ -115,11 +109,10 @@ export declare function sealSlotlockAssistantKey(secret: string, tenantRef: stri
|
|
|
115
109
|
/** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
|
|
116
110
|
export declare function openSlotlockAssistantKey(secret: string, tenantRef: string, sealed: string): Promise<string | null>;
|
|
117
111
|
/**
|
|
118
|
-
* A provider base URL as stored: https, or http on localhost or 127.0.0.1
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* to one is refused when it is resolved (checkSlotlockAssistantHost, and at each connection).
|
|
112
|
+
* A provider base URL as stored: https, or http on localhost or 127.0.0.1; no credentials, query,
|
|
113
|
+
* fragment or trailing slash. Unless `privateUrls`, a host that is `localhost` or a private-range
|
|
114
|
+
* IP literal is refused as `private`; a name resolving to one is refused when resolved
|
|
115
|
+
* (checkSlotlockAssistantHost, and at each connection).
|
|
123
116
|
*/
|
|
124
117
|
export declare function readSlotlockAssistantBaseUrl(value: string, options?: {
|
|
125
118
|
privateUrls?: boolean;
|
|
@@ -131,10 +124,9 @@ export declare function readSlotlockAssistantBaseUrl(value: string, options?: {
|
|
|
131
124
|
reason: 'invalid' | 'private';
|
|
132
125
|
};
|
|
133
126
|
/**
|
|
134
|
-
* Whether a base URL's host resolves only to public addresses
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* here: the connection fails later, plainly.
|
|
127
|
+
* Whether a base URL's host resolves only to public addresses (`resolve`: dns.lookup on Node.js).
|
|
128
|
+
* True with `privateUrls` or an IP literal already checked; a name that does not resolve is not
|
|
129
|
+
* refused here: the connection fails later.
|
|
138
130
|
*/
|
|
139
131
|
export declare function checkSlotlockAssistantHost(baseUrl: string, options: {
|
|
140
132
|
privateUrls?: boolean;
|
|
@@ -239,14 +231,13 @@ export interface SlotlockAssistantTurnResult {
|
|
|
239
231
|
*/
|
|
240
232
|
export declare function runSlotlockAssistantTurn(options: SlotlockAssistantTurnOptions): Promise<SlotlockAssistantTurnResult>;
|
|
241
233
|
/**
|
|
242
|
-
* The conversation within
|
|
243
|
-
*
|
|
244
|
-
* kept without the call it answers).
|
|
234
|
+
* The conversation within MAX_ENTRIES and the stored-bytes bound, oldest exchanges dropped whole,
|
|
235
|
+
* so it always starts at a member's message (a tool result is never kept without its call).
|
|
245
236
|
*/
|
|
246
237
|
export declare function boundSlotlockAssistantEntries(entries: readonly SlotlockAssistantEntry[]): SlotlockAssistantEntry[];
|
|
247
238
|
/**
|
|
248
|
-
* The actions a conversation keeps: those its
|
|
249
|
-
*
|
|
239
|
+
* The actions a conversation keeps: those its entries show, oldest settled ones dropped first past
|
|
240
|
+
* MAX_ACTIONS_BYTES (waiting ones are bounded by SLOTLOCK_ASSISTANT_MAX_PENDING).
|
|
250
241
|
*/
|
|
251
242
|
export declare function pruneSlotlockAssistantActions(entries: readonly SlotlockAssistantEntry[], actions: Readonly<Record<string, Record<string, unknown>>>): Record<string, Record<string, unknown>>;
|
|
252
243
|
/** How many of a conversation's actions wait for the member. */
|
package/assistant.js
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
// The dashboard assistant: an organization connects
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// approves on a form before it runs. Nothing the model says changes the list of tools.
|
|
1
|
+
// The dashboard assistant: an organization connects its own model (the Anthropic Messages API, or
|
|
2
|
+
// any OpenAI-compatible chat completions endpoint) and its members operate Slotlock by chat. The
|
|
3
|
+
// server runs the tool loop with a fixed tool list: reads go through the agent operation boundary
|
|
4
|
+
// (schemas, scopes, limits), and every write becomes an action the member approves before it runs.
|
|
5
|
+
// Nothing the model says changes the list of tools.
|
|
7
6
|
//
|
|
8
|
-
// Request formats
|
|
9
|
-
// and
|
|
10
|
-
// JSON-string arguments, role "tool" results), as the vendors' official TypeScript SDKs describe
|
|
11
|
-
// them: @anthropic-ai/sdk 1.3.0 and openai 7.45.1, read from the npm registry 2026-10-28.
|
|
7
|
+
// Request formats as the vendors' official TypeScript SDKs describe them: @anthropic-ai/sdk 1.3.0
|
|
8
|
+
// and openai 7.45.1 (read from the npm registry 2026-10-28).
|
|
12
9
|
//
|
|
13
|
-
// The API key is sealed with AES-256-GCM (WebCrypto
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// The API key is sealed with AES-256-GCM (WebCrypto) under a key HKDF-SHA-256-derived from
|
|
11
|
+
// SLOTLOCK_ASSISTANT_SECRET, with the tenant as additional data: a sealed key copied to another
|
|
12
|
+
// tenant's row does not open.
|
|
16
13
|
import { slotlockAgentOperationScope, slotlockAgentTools, } from './agent-server.js';
|
|
17
14
|
import { isIP } from 'node:net';
|
|
18
15
|
import { isPrivateAddress } from './webhooks.js';
|
|
19
16
|
/**
|
|
20
|
-
* The tools the model is offered, by agent operation. A record over every operation, so
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* offer them.
|
|
17
|
+
* The tools the model is offered, by agent operation. A record over every operation, so a new one
|
|
18
|
+
* does not compile until placed here. Reads run when called; writes are only proposed and run on
|
|
19
|
+
* approval. API keys, webhooks, booking rules, pools, erasure and coverage are application
|
|
20
|
+
* operations, never agent ones, so none can be offered.
|
|
25
21
|
*/
|
|
26
22
|
export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
|
|
27
23
|
slotlock_list_resources: 'read',
|
|
@@ -52,9 +48,8 @@ export const SLOTLOCK_ASSISTANT_MAX_MESSAGE = 2_000;
|
|
|
52
48
|
/** Entries a conversation keeps; the oldest exchanges go first. */
|
|
53
49
|
const MAX_ENTRIES = 60;
|
|
54
50
|
/**
|
|
55
|
-
* UTF-8 bytes of the stored conversation as JSON
|
|
56
|
-
*
|
|
57
|
-
* table's 262,144-byte CHECK on `messages::text` whatever the text.
|
|
51
|
+
* UTF-8 bytes of the stored conversation as JSON. jsonb text adds at most one space per `:` or `,`,
|
|
52
|
+
* so this stays under the table's 262,144-byte CHECK on `messages::text`.
|
|
58
53
|
*/
|
|
59
54
|
export const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160_000;
|
|
60
55
|
/** UTF-8 bytes of the kept actions as JSON: under the 131,072-byte CHECK on `actions::text` as above. */
|
|
@@ -72,15 +67,10 @@ const MAX_CALLS_PER_STEP = 8;
|
|
|
72
67
|
/** Bytes of one provider answer. */
|
|
73
68
|
const MAX_RESPONSE_BYTES = 1_048_576;
|
|
74
69
|
const MAX_OUTPUT_TOKENS = 1_024;
|
|
75
|
-
/**
|
|
76
|
-
* The Messages API version this sends: the one @anthropic-ai/sdk 1.3.0 (2026-10-23) sends, read
|
|
77
|
-
* from its source on 2026-10-28. Top-level `system`, `tools[].input_schema`, `tool_use` and
|
|
78
|
-
* `tool_result` blocks are as before in it.
|
|
79
|
-
*/
|
|
70
|
+
/** The Messages API version @anthropic-ai/sdk 1.3.0 sends (read from its source 2026-10-28). */
|
|
80
71
|
export const SLOTLOCK_ANTHROPIC_VERSION = '2026-10-21';
|
|
81
72
|
/** The assistant's records in the store, each in a transaction under the tenant's row-level security. */
|
|
82
73
|
export function createSlotlockAssistantStore(store) {
|
|
83
|
-
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
84
74
|
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
85
75
|
return {
|
|
86
76
|
getSettings: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAssistantSettings({ tenantRef })),
|
|
@@ -90,8 +80,8 @@ export function createSlotlockAssistantStore(store) {
|
|
|
90
80
|
...(actor !== undefined ? { updatedBy: actor } : {}),
|
|
91
81
|
}), by(actor)),
|
|
92
82
|
deleteSettings: (tenantRef, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAssistantSettings({ tenantRef }), by(actor)),
|
|
93
|
-
// Each read deletes the tenant's expired conversations first,
|
|
94
|
-
//
|
|
83
|
+
// Each read deletes the tenant's expired conversations first, for deployments whose maintenance
|
|
84
|
+
// never meets the tenant (a Worker's).
|
|
95
85
|
getConversation: (tenantRef, memberRef) => store.withTenant(tenantRef, async (tenant) => {
|
|
96
86
|
await tenant.pruneAssistantConversations({ tenantRef, limit: 100 });
|
|
97
87
|
return tenant.getAssistantConversation({ tenantRef, memberRef });
|
|
@@ -156,7 +146,7 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
156
146
|
},
|
|
157
147
|
async putConversation(tenantRef, memberRef, conversation, ttlMs) {
|
|
158
148
|
const stored = live(tenantRef, memberRef);
|
|
159
|
-
// As the store:
|
|
149
|
+
// As the store does: stored ones kept as they stand, then new ones not stored already.
|
|
160
150
|
const actions = {};
|
|
161
151
|
for (const id of conversation.keep) {
|
|
162
152
|
const kept = stored?.actions[id];
|
|
@@ -188,7 +178,6 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
188
178
|
},
|
|
189
179
|
};
|
|
190
180
|
}
|
|
191
|
-
// ---- The key, sealed ----
|
|
192
181
|
const SEAL_VERSION = 'v1';
|
|
193
182
|
const encoder = new TextEncoder();
|
|
194
183
|
async function sealingKey(secret) {
|
|
@@ -226,7 +215,6 @@ export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
|
|
|
226
215
|
return null;
|
|
227
216
|
}
|
|
228
217
|
}
|
|
229
|
-
// ---- Settings ----
|
|
230
218
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
231
219
|
/** Whether a URL's host names this machine or a private network by itself: `localhost`, or an IP literal isPrivateAddress refuses. */
|
|
232
220
|
function namesPrivateHost(url) {
|
|
@@ -236,11 +224,10 @@ function namesPrivateHost(url) {
|
|
|
236
224
|
return isIP(host) !== 0 && isPrivateAddress(host);
|
|
237
225
|
}
|
|
238
226
|
/**
|
|
239
|
-
* A provider base URL as stored: https, or http on localhost or 127.0.0.1
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* to one is refused when it is resolved (checkSlotlockAssistantHost, and at each connection).
|
|
227
|
+
* A provider base URL as stored: https, or http on localhost or 127.0.0.1; no credentials, query,
|
|
228
|
+
* fragment or trailing slash. Unless `privateUrls`, a host that is `localhost` or a private-range
|
|
229
|
+
* IP literal is refused as `private`; a name resolving to one is refused when resolved
|
|
230
|
+
* (checkSlotlockAssistantHost, and at each connection).
|
|
244
231
|
*/
|
|
245
232
|
export function readSlotlockAssistantBaseUrl(value, options = {}) {
|
|
246
233
|
let url;
|
|
@@ -263,10 +250,9 @@ export function readSlotlockAssistantBaseUrl(value, options = {}) {
|
|
|
263
250
|
return { ok: true, url: href };
|
|
264
251
|
}
|
|
265
252
|
/**
|
|
266
|
-
* Whether a base URL's host resolves only to public addresses
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* here: the connection fails later, plainly.
|
|
253
|
+
* Whether a base URL's host resolves only to public addresses (`resolve`: dns.lookup on Node.js).
|
|
254
|
+
* True with `privateUrls` or an IP literal already checked; a name that does not resolve is not
|
|
255
|
+
* refused here: the connection fails later.
|
|
270
256
|
*/
|
|
271
257
|
export async function checkSlotlockAssistantHost(baseUrl, options) {
|
|
272
258
|
if (options.privateUrls === true || !options.resolve)
|
|
@@ -316,7 +302,6 @@ export function slotlockAssistantTools() {
|
|
|
316
302
|
];
|
|
317
303
|
});
|
|
318
304
|
}
|
|
319
|
-
// ---- Providers ----
|
|
320
305
|
/** Why a provider call failed, in words safe to show (never the key, never the provider's body). */
|
|
321
306
|
export class SlotlockAssistantProviderError extends Error {
|
|
322
307
|
reason;
|
|
@@ -473,7 +458,7 @@ function readOpenai(body) {
|
|
|
473
458
|
catch {
|
|
474
459
|
input = null;
|
|
475
460
|
}
|
|
476
|
-
// Some local servers
|
|
461
|
+
// Some local servers omit the id; one is needed to pair the result.
|
|
477
462
|
const id = typeof call.id === 'string' && call.id !== '' ? call.id : `call_${index}`;
|
|
478
463
|
calls.push({ id, name: call.function.name, input });
|
|
479
464
|
}
|
|
@@ -523,7 +508,7 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
523
508
|
method: 'POST',
|
|
524
509
|
headers,
|
|
525
510
|
body: JSON.stringify(anthropic ? anthropicBody(request) : openaiBody(request)),
|
|
526
|
-
//
|
|
511
|
+
// Never follow a redirect: it would carry the key elsewhere.
|
|
527
512
|
redirect: 'manual',
|
|
528
513
|
signal: request.signal,
|
|
529
514
|
});
|
|
@@ -675,24 +660,22 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
675
660
|
return { added };
|
|
676
661
|
}
|
|
677
662
|
/**
|
|
678
|
-
* The conversation within
|
|
679
|
-
*
|
|
680
|
-
* kept without the call it answers).
|
|
663
|
+
* The conversation within MAX_ENTRIES and the stored-bytes bound, oldest exchanges dropped whole,
|
|
664
|
+
* so it always starts at a member's message (a tool result is never kept without its call).
|
|
681
665
|
*/
|
|
682
666
|
export function boundSlotlockAssistantEntries(entries) {
|
|
683
|
-
//
|
|
667
|
+
// jsonb holds no NUL character: one would fail the write.
|
|
684
668
|
let kept = JSON.parse(JSON.stringify(entries, (_key, value) => typeof value === 'string' ? value.replaceAll('\u0000', '') : value));
|
|
685
669
|
const tooBig = () => kept.length > MAX_ENTRIES ||
|
|
686
670
|
Buffer.byteLength(JSON.stringify(kept), 'utf8') > SLOTLOCK_ASSISTANT_MAX_STORED_BYTES;
|
|
687
|
-
// The oldest exchanges first, whole.
|
|
688
671
|
while (tooBig()) {
|
|
689
672
|
const next = kept.findIndex((entry, index) => index > 0 && entry.role === 'user');
|
|
690
673
|
if (next === -1)
|
|
691
674
|
break;
|
|
692
675
|
kept = kept.slice(next);
|
|
693
676
|
}
|
|
694
|
-
//
|
|
695
|
-
//
|
|
677
|
+
// An exchange too big alone loses its steps from the oldest, non-proposing ones first; the
|
|
678
|
+
// member's message and the last answer stay.
|
|
696
679
|
for (const keepProposals of [true, false]) {
|
|
697
680
|
while (tooBig()) {
|
|
698
681
|
const step = kept.findIndex((entry, index) => entry.role === 'assistant' &&
|
|
@@ -711,8 +694,8 @@ export function boundSlotlockAssistantEntries(entries) {
|
|
|
711
694
|
return tooBig() ? [] : kept;
|
|
712
695
|
}
|
|
713
696
|
/**
|
|
714
|
-
* The actions a conversation keeps: those its
|
|
715
|
-
*
|
|
697
|
+
* The actions a conversation keeps: those its entries show, oldest settled ones dropped first past
|
|
698
|
+
* MAX_ACTIONS_BYTES (waiting ones are bounded by SLOTLOCK_ASSISTANT_MAX_PENDING).
|
|
716
699
|
*/
|
|
717
700
|
export function pruneSlotlockAssistantActions(entries, actions) {
|
|
718
701
|
const shown = [];
|
package/booking-rules.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Interval, WeeklyAvailabilityRule } from './types.js';
|
|
2
2
|
/**
|
|
3
|
-
* A resource's booking rules: what a booking made through Slotlock (
|
|
4
|
-
*
|
|
5
|
-
* Imported calendar events are what happened elsewhere and are not checked.
|
|
3
|
+
* A resource's booking rules: what a booking made through Slotlock (hold, reservation, agent event)
|
|
4
|
+
* must satisfy, and what a search offers. All optional. Imported calendar events are not checked.
|
|
6
5
|
*/
|
|
7
6
|
export interface SlotlockBookingRules {
|
|
8
7
|
/** A booking starts at least this many minutes after it is made: 0-525,600 (a year). */
|
|
@@ -15,35 +14,32 @@ export interface SlotlockBookingRules {
|
|
|
15
14
|
*/
|
|
16
15
|
slotStepMinutes?: number;
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* kept and does nothing.
|
|
17
|
+
* Offset of the steps from local midnight (with a 30-minute step, 15 starts bookings on :15 and
|
|
18
|
+
* :45): 0 to one less than the step. Ignored without a step.
|
|
21
19
|
*/
|
|
22
20
|
stepOffsetMinutes?: number;
|
|
23
21
|
/**
|
|
24
|
-
* Minutes kept free before each booking made under the rule
|
|
25
|
-
*
|
|
26
|
-
* it. 0-1,440. A booking made before the rule, or imported, keeps none.
|
|
22
|
+
* Minutes kept free before each booking made under the rule, as its leading buffer: 0-1,440.
|
|
23
|
+
* Bookings made before the rule, or imported, keep none.
|
|
27
24
|
*/
|
|
28
25
|
bufferBeforeMinutes?: number;
|
|
29
26
|
/**
|
|
30
|
-
* Minutes kept free after each booking made under the rule
|
|
31
|
-
*
|
|
32
|
-
* booking made before the rule, or imported, keeps the buffer it has.
|
|
27
|
+
* Minutes kept free after each booking made under the rule, as its trailing buffer: 0-1,440.
|
|
28
|
+
* Bookings made before the rule, or imported, keep what they have.
|
|
33
29
|
*/
|
|
34
30
|
turnaroundMinutes?: number;
|
|
35
31
|
/** A booking lies inside the resource's bookable hours. */
|
|
36
32
|
enforceHours?: boolean;
|
|
37
33
|
/**
|
|
38
|
-
* A booking
|
|
39
|
-
*
|
|
40
|
-
*
|
|
34
|
+
* A booking must be checked in within this many minutes of its start, or maintenance releases it
|
|
35
|
+
* (and offers it to the waitlist): 1-1,440. Only bookings made after the rule is set are
|
|
36
|
+
* released.
|
|
41
37
|
*/
|
|
42
38
|
checkInMinutes?: number;
|
|
43
39
|
/**
|
|
44
|
-
* A booking
|
|
45
|
-
*
|
|
46
|
-
*
|
|
40
|
+
* A booking must lie in time every sync covering the resource read within the last day
|
|
41
|
+
* (`recordCalendarCoverage`): time a stale or failing sync leaves unproven is refused
|
|
42
|
+
* (`uncertain`). A resource no sync covers is certain.
|
|
47
43
|
*/
|
|
48
44
|
requireCertain?: boolean;
|
|
49
45
|
}
|
|
@@ -62,20 +58,18 @@ export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
|
|
|
62
58
|
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
63
59
|
export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
|
|
64
60
|
/**
|
|
65
|
-
* The rules
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* reads as refusing every booking.
|
|
61
|
+
* The effective rules merged from the resource's own, its pool's and the tenant's (each validated
|
|
62
|
+
* when set), read leniently: each field is kept if valid alone, else named in `dropped`. A pool's
|
|
63
|
+
* offset over a resource's shorter step applies modulo the step. A merge never refuses every
|
|
64
|
+
* booking.
|
|
70
65
|
*/
|
|
71
66
|
export declare function parseEffectiveBookingRules(value: unknown): {
|
|
72
67
|
rules: SlotlockBookingRules;
|
|
73
68
|
dropped: string[];
|
|
74
69
|
};
|
|
75
70
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* was set, and that moment is the resource's).
|
|
71
|
+
* Tenant or pool default rules from untrusted input: no `checkInMinutes`, which only a resource's
|
|
72
|
+
* own rules set (it applies from the moment it was set on the resource).
|
|
79
73
|
*/
|
|
80
74
|
export declare function parseBookingRuleDefaults(value: unknown): SlotlockBookingRules;
|
|
81
75
|
/** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
|
|
@@ -83,15 +77,14 @@ export declare function bookingBufferAfterMs(rules: SlotlockBookingRules | undef
|
|
|
83
77
|
/** The leading buffer a booking made under the rules keeps. */
|
|
84
78
|
export declare function bookingBufferBeforeMs(rules: SlotlockBookingRules | undefined): number;
|
|
85
79
|
/**
|
|
86
|
-
* The first instant at or after `from`
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* local time is on a step.
|
|
80
|
+
* The first instant at or after `from` on a step in `timezone`, `offsetMinutes` past each multiple
|
|
81
|
+
* of the step from local midnight. A step that does not divide the day restarts at local midnight;
|
|
82
|
+
* across a clock change it moves forward to the next local step.
|
|
90
83
|
*/
|
|
91
84
|
export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string, offsetMinutes?: number): Date;
|
|
92
85
|
/**
|
|
93
|
-
* The rule a booking of `interval` made at `now` breaks, or null. `hours`
|
|
94
|
-
*
|
|
86
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
|
|
87
|
+
* the default) are read only when enforced.
|
|
95
88
|
*/
|
|
96
89
|
export declare function bookingRuleBroken(params: {
|
|
97
90
|
rules: SlotlockBookingRules | undefined;
|
|
@@ -113,8 +106,7 @@ export interface SlotlockBookingSearch {
|
|
|
113
106
|
}
|
|
114
107
|
/**
|
|
115
108
|
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
116
|
-
* buffers each slot keeps, and how a candidate
|
|
117
|
-
* window is allowed.
|
|
109
|
+
* buffers each slot keeps, and how a candidate moves to a step. `null` when no start is allowed.
|
|
118
110
|
*/
|
|
119
111
|
export declare function bookingSearch(params: {
|
|
120
112
|
rules: SlotlockBookingRules | undefined;
|
|
@@ -123,14 +115,11 @@ export declare function bookingSearch(params: {
|
|
|
123
115
|
now: Date;
|
|
124
116
|
bufferAfterMs?: number;
|
|
125
117
|
}): SlotlockBookingSearch | null;
|
|
126
|
-
/**
|
|
127
|
-
* The time a search must read as free or busy: its window, widened by the buffers a slot in it
|
|
128
|
-
* keeps (before its first start and after its last end).
|
|
129
|
-
*/
|
|
118
|
+
/** The time a search must read as free or busy: its window widened by a slot's buffers. */
|
|
130
119
|
export declare function bookingSearchReach(search: SlotlockBookingSearch): Interval;
|
|
131
120
|
/**
|
|
132
|
-
* Busy time as the slot times it rules out
|
|
133
|
-
*
|
|
134
|
-
*
|
|
121
|
+
* Busy time as the slot times it rules out: a slot [s, s + d) with buffers occupies [s - before, s
|
|
122
|
+
* + d + after), so it meets busy [b, e) exactly when [s, s + d) meets [b - after, e + before), as
|
|
123
|
+
* the database decides.
|
|
135
124
|
*/
|
|
136
125
|
export declare function bookingSearchBlocked(busy: readonly Interval[], search: SlotlockBookingSearch): Interval[];
|
package/booking-rules.js
CHANGED
|
@@ -54,11 +54,10 @@ export function parseBookingRules(value) {
|
|
|
54
54
|
return rules;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* The rules
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* reads as refusing every booking.
|
|
57
|
+
* The effective rules merged from the resource's own, its pool's and the tenant's (each validated
|
|
58
|
+
* when set), read leniently: each field is kept if valid alone, else named in `dropped`. A pool's
|
|
59
|
+
* offset over a resource's shorter step applies modulo the step. A merge never refuses every
|
|
60
|
+
* booking.
|
|
62
61
|
*/
|
|
63
62
|
export function parseEffectiveBookingRules(value) {
|
|
64
63
|
const rules = {};
|
|
@@ -88,9 +87,8 @@ export function parseEffectiveBookingRules(value) {
|
|
|
88
87
|
return { rules, dropped };
|
|
89
88
|
}
|
|
90
89
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* was set, and that moment is the resource's).
|
|
90
|
+
* Tenant or pool default rules from untrusted input: no `checkInMinutes`, which only a resource's
|
|
91
|
+
* own rules set (it applies from the moment it was set on the resource).
|
|
94
92
|
*/
|
|
95
93
|
export function parseBookingRuleDefaults(value) {
|
|
96
94
|
const rules = parseBookingRules(value);
|
|
@@ -123,10 +121,9 @@ function onStep(instant, stepMinutes, timezone, offset = 0) {
|
|
|
123
121
|
return Number.isInteger(minutes) && minutes >= offset && (minutes - offset) % stepMinutes === 0;
|
|
124
122
|
}
|
|
125
123
|
/**
|
|
126
|
-
* The first instant at or after `from`
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* local time is on a step.
|
|
124
|
+
* The first instant at or after `from` on a step in `timezone`, `offsetMinutes` past each multiple
|
|
125
|
+
* of the step from local midnight. A step that does not divide the day restarts at local midnight;
|
|
126
|
+
* across a clock change it moves forward to the next local step.
|
|
130
127
|
*/
|
|
131
128
|
export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
|
|
132
129
|
const offset = offsetMinutes % stepMinutes;
|
|
@@ -142,8 +139,8 @@ export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
|
|
|
142
139
|
return candidate;
|
|
143
140
|
}
|
|
144
141
|
/**
|
|
145
|
-
* The rule a booking of `interval` made at `now` breaks, or null. `hours`
|
|
146
|
-
*
|
|
142
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
|
|
143
|
+
* the default) are read only when enforced.
|
|
147
144
|
*/
|
|
148
145
|
export function bookingRuleBroken(params) {
|
|
149
146
|
const { rules, interval, now } = params;
|
|
@@ -178,8 +175,7 @@ export function bookingRuleBroken(params) {
|
|
|
178
175
|
}
|
|
179
176
|
/**
|
|
180
177
|
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
181
|
-
* buffers each slot keeps, and how a candidate
|
|
182
|
-
* window is allowed.
|
|
178
|
+
* buffers each slot keeps, and how a candidate moves to a step. `null` when no start is allowed.
|
|
183
179
|
*/
|
|
184
180
|
export function bookingSearch(params) {
|
|
185
181
|
const { rules, window, now } = params;
|
|
@@ -195,7 +191,6 @@ export function bookingSearch(params) {
|
|
|
195
191
|
const toStep = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
|
|
196
192
|
const searched = { start: new Date(start), end: new Date(end) };
|
|
197
193
|
if (rules?.maxAdvanceDays !== undefined) {
|
|
198
|
-
// The latest allowed start bounds where a slot may start; it may still end after it.
|
|
199
194
|
const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
|
|
200
195
|
if (latestStart < start || end <= start)
|
|
201
196
|
return null;
|
|
@@ -205,7 +200,7 @@ export function bookingSearch(params) {
|
|
|
205
200
|
bufferBeforeMs,
|
|
206
201
|
align: (from) => {
|
|
207
202
|
const aligned = toStep ? toStep(from) : from;
|
|
208
|
-
// Past the latest start
|
|
203
|
+
// Past the latest start: move it beyond the window so it cannot fit.
|
|
209
204
|
return aligned.getTime() > latestStart ? new Date(end + 1) : aligned;
|
|
210
205
|
},
|
|
211
206
|
};
|
|
@@ -216,10 +211,7 @@ export function bookingSearch(params) {
|
|
|
216
211
|
? { window: searched, bufferAfterMs, bufferBeforeMs }
|
|
217
212
|
: { window: searched, bufferAfterMs, bufferBeforeMs, align: toStep };
|
|
218
213
|
}
|
|
219
|
-
/**
|
|
220
|
-
* The time a search must read as free or busy: its window, widened by the buffers a slot in it
|
|
221
|
-
* keeps (before its first start and after its last end).
|
|
222
|
-
*/
|
|
214
|
+
/** The time a search must read as free or busy: its window widened by a slot's buffers. */
|
|
223
215
|
export function bookingSearchReach(search) {
|
|
224
216
|
return {
|
|
225
217
|
start: new Date(search.window.start.getTime() - search.bufferBeforeMs),
|
|
@@ -227,9 +219,9 @@ export function bookingSearchReach(search) {
|
|
|
227
219
|
};
|
|
228
220
|
}
|
|
229
221
|
/**
|
|
230
|
-
* Busy time as the slot times it rules out
|
|
231
|
-
*
|
|
232
|
-
*
|
|
222
|
+
* Busy time as the slot times it rules out: a slot [s, s + d) with buffers occupies [s - before, s
|
|
223
|
+
* + d + after), so it meets busy [b, e) exactly when [s, s + d) meets [b - after, e + before), as
|
|
224
|
+
* the database decides.
|
|
233
225
|
*/
|
|
234
226
|
export function bookingSearchBlocked(busy, search) {
|
|
235
227
|
return busy.map((interval) => ({
|
package/change-watch.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface SlotlockChangeWatcher {
|
|
|
45
45
|
export declare const SLOTLOCK_FEED_BUSY_MS = 250;
|
|
46
46
|
/** How often a quiet tenant's feed is read, in ms: the poll backs off to this from the busy rate. */
|
|
47
47
|
export declare const SLOTLOCK_FEED_IDLE_MS = 1000;
|
|
48
|
+
export declare function unref<T>(timer: T): T;
|
|
48
49
|
/**
|
|
49
50
|
* The readers of one server process. `join` adds a watcher to its tenant's reader (starting it) and
|
|
50
51
|
* returns the function that removes it (stopping the reader with its last watcher).
|
package/change-watch.js
CHANGED
|
@@ -17,7 +17,7 @@ export const SLOTLOCK_FEED_BUSY_MS = 250;
|
|
|
17
17
|
export const SLOTLOCK_FEED_IDLE_MS = 1_000;
|
|
18
18
|
/** How often a quiet feed is read while a custom source's `watch` is listening, in ms. */
|
|
19
19
|
const FEED_LISTENING_IDLE_MS = 15_000;
|
|
20
|
-
function unref(timer) {
|
|
20
|
+
export function unref(timer) {
|
|
21
21
|
timer.unref?.();
|
|
22
22
|
return timer;
|
|
23
23
|
}
|
package/cli.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The `slotlock` executable: process wiring only
|
|
3
|
-
// and the exit code). The commands and their configuration live in self-host.ts.
|
|
2
|
+
// The `slotlock` executable: process wiring only; the commands live in self-host.ts.
|
|
4
3
|
import { runSlotlockCli } from './self-host.js';
|
|
5
4
|
const shutdown = new AbortController();
|
|
6
5
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
@@ -17,6 +16,6 @@ process.exitCode = await runSlotlockCli(process.argv.slice(2), {
|
|
|
17
16
|
stderr: process.stderr,
|
|
18
17
|
signal: shutdown.signal,
|
|
19
18
|
});
|
|
20
|
-
// Everything is closed
|
|
21
|
-
//
|
|
19
|
+
// Everything is closed, so the process should end on its own; exit anyway if a stray handle would
|
|
20
|
+
// hang a container stop.
|
|
22
21
|
setTimeout(() => process.exit(), 5_000).unref();
|
package/config.d.ts
CHANGED
|
@@ -48,8 +48,14 @@ export interface SlotlockServeDashboardConfig {
|
|
|
48
48
|
assistantPrivateUrls?: boolean;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
* dashboard
|
|
51
|
+
* SLOTLOCK_DASHBOARD_USERS: `'*'`, GitHub user ids, or undefined when it is not set. A server whose
|
|
52
|
+
* dashboard runs elsewhere (a Worker) reads it too, so that removing a person there stops their keys.
|
|
53
|
+
*/
|
|
54
|
+
export declare function readDashboardUsers(env: SlotlockEnv): '*' | string[] | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The dashboard's settings, or undefined when none of DASHBOARD_VARIABLES is set. Setting any of
|
|
57
|
+
* them means serving the dashboard, so the rest, and SLOTLOCK_DASHBOARD_USERS, are then required: a
|
|
58
|
+
* half-configured sign-in is a startup error.
|
|
53
59
|
*/
|
|
54
60
|
export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDashboardConfig | undefined;
|
|
55
61
|
/**
|
|
@@ -154,4 +160,13 @@ export declare function readLimits(env: SlotlockEnv): SlotlockLimits;
|
|
|
154
160
|
* only when nothing reaches the server except through that proxy.
|
|
155
161
|
*/
|
|
156
162
|
export declare function readTrustedProxyHeader(env: SlotlockEnv): string | undefined;
|
|
163
|
+
/** The request field that carries SLOTLOCK_TRUSTED_PROXY_SECRET; not cf-…, which Cloudflare's rules may not set. */
|
|
164
|
+
export declare const SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD = "x-slotlock-proxy-proof";
|
|
165
|
+
/**
|
|
166
|
+
* SLOTLOCK_TRUSTED_PROXY_SECRET: a value the proxy in front adds to every request in
|
|
167
|
+
* SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, such as a Cloudflare transform rule. With it, the trusted
|
|
168
|
+
* proxy field is believed only beside that value, so a process that reaches the server without
|
|
169
|
+
* passing the proxy (another container on its network) cannot choose its own client address.
|
|
170
|
+
*/
|
|
171
|
+
export declare function readTrustedProxySecret(env: SlotlockEnv, trustedProxyHeader: string | undefined): string | undefined;
|
|
157
172
|
export {};
|