slotlock 0.6.1 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -6
  3. package/SECURITY.md +15 -4
  4. package/agent-server.js +1 -5
  5. package/agent-store-backend.d.ts +5 -6
  6. package/agent-store-backend.js +41 -67
  7. package/api-keys.js +1 -1
  8. package/application-api.d.ts +3 -3
  9. package/application-api.js +16 -24
  10. package/assistant.d.ts +18 -27
  11. package/assistant.js +36 -53
  12. package/booking-rules.d.ts +30 -41
  13. package/booking-rules.js +17 -25
  14. package/change-watch.d.ts +1 -0
  15. package/change-watch.js +1 -1
  16. package/cli.js +3 -4
  17. package/config.d.ts +17 -2
  18. package/config.js +44 -6
  19. package/dashboard.js +14 -1
  20. package/ddl.d.ts +20 -27
  21. package/ddl.js +166 -108
  22. package/engine.d.ts +6 -9
  23. package/engine.js +5 -9
  24. package/event-feed.d.ts +23 -31
  25. package/event-feed.js +30 -45
  26. package/event-schema.js +6 -9
  27. package/hooks.d.ts +2 -4
  28. package/hooks.js +2 -4
  29. package/ical-worker-thread.js +3 -4
  30. package/ical-worker.d.ts +8 -10
  31. package/ical-worker.js +7 -9
  32. package/interop.js +1 -4
  33. package/maintenance.d.ts +14 -10
  34. package/maintenance.js +43 -26
  35. package/mcp-app.d.ts +7 -9
  36. package/mcp-app.js +7 -9
  37. package/mcp-modern.d.ts +14 -16
  38. package/mcp-modern.js +23 -27
  39. package/metrics.js +0 -1
  40. package/node-server.d.ts +9 -9
  41. package/node-server.js +25 -34
  42. package/package-version.js +2 -4
  43. package/package.json +1 -1
  44. package/rate-limit.d.ts +6 -1
  45. package/rate-limit.js +24 -8
  46. package/rest-api.d.ts +13 -14
  47. package/rest-api.js +11 -12
  48. package/rules.d.ts +6 -7
  49. package/rules.js +30 -42
  50. package/self-host.d.ts +16 -2
  51. package/self-host.js +42 -21
  52. package/slotlock.manifest.json +1 -1
  53. package/store.d.ts +109 -161
  54. package/store.js +253 -371
  55. package/sync.d.ts +44 -59
  56. package/sync.js +325 -469
  57. package/timezone.d.ts +25 -33
  58. package/timezone.js +37 -52
  59. package/types.d.ts +35 -56
  60. package/webhooks.d.ts +22 -25
  61. package/webhooks.js +35 -40
  62. package/weekly-hours.d.ts +3 -0
  63. 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 one a later
8
- * release adds does not compile until it is placed here. Reads run when the model calls them;
9
- * every write is only proposed, and runs when the member approves it. API keys, webhooks, booking
10
- * rules, pools, erasure and coverage are application operations, never agent ones, so no entry can
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, past which the oldest exchanges go first.
20
- * PostgreSQL's jsonb text adds at most one space per `:` or `,`, so at most half again: under the
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 (a model on the server's
119
- * own machine); no credentials, query or fragment; no trailing slash. Unless `privateUrls`, a URL
120
- * whose host is `localhost` or a loopback, private, link-local, shared (CGNAT), unique-local or
121
- * metadata address (the webhooks' isPrivateAddress) is refused as `private`; a name that resolves
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: `resolve` lists them (dns.lookup's,
135
- * on Node.js). True when `privateUrls`, or when the host is an IP literal readSlotlockAssistantBaseUrl
136
- * already checked; false when any address is private. A name that does not resolve is not refused
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 its bounds: at most MAX_ENTRIES entries and MAX_STORED_CHARACTERS, the
243
- * oldest exchanges dropped whole, so it always starts at a member's message (a tool result is never
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 kept entries show, the oldest settled ones dropped
249
- * first past MAX_ACTIONS_BYTES (a waiting one is kept: SLOTLOCK_ASSISTANT_MAX_PENDING bounds them).
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 a model of its own (the Anthropic Messages API,
2
- // or any OpenAI-compatible chat completions endpoint: OpenAI, OpenRouter, vLLM, Ollama, LM Studio)
3
- // and its members operate Slotlock by chat. The server runs the tool loop: it calls the model with a
4
- // fixed list of agent tools, runs reads through the agent operation boundary (schemas, scopes,
5
- // authorization and limits, as for any agent) and turns every write into an action the member
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: the Messages API (POST <base>/v1/messages, x-api-key, anthropic-version; tool_use
9
- // and tool_result blocks) and Chat Completions (POST <base>/chat/completions, Bearer; tool_calls with
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, in Node.js and Workers alike) under a key
14
- // derived by HKDF-SHA-256 from the deployment's SLOTLOCK_ASSISTANT_SECRET, with the tenant as
15
- // additional data: a sealed key copied to another tenant's row does not open.
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 one a later
21
- * release adds does not compile until it is placed here. Reads run when the model calls them;
22
- * every write is only proposed, and runs when the member approves it. API keys, webhooks, booking
23
- * rules, pools, erasure and coverage are application operations, never agent ones, so no entry can
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, past which the oldest exchanges go first.
56
- * PostgreSQL's jsonb text adds at most one space per `:` or `,`, so at most half again: under the
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, so they go on a deployment whose
94
- // maintenance never meets the tenant (a Worker's) too.
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: the stored ones kept as they stand, then the new ones not stored already.
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 (a model on the server's
240
- * own machine); no credentials, query or fragment; no trailing slash. Unless `privateUrls`, a URL
241
- * whose host is `localhost` or a loopback, private, link-local, shared (CGNAT), unique-local or
242
- * metadata address (the webhooks' isPrivateAddress) is refused as `private`; a name that resolves
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: `resolve` lists them (dns.lookup's,
267
- * on Node.js). True when `privateUrls`, or when the host is an IP literal readSlotlockAssistantBaseUrl
268
- * already checked; false when any address is private. A name that does not resolve is not refused
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 leave the id out; one is needed to pair the result.
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
- // A redirect is not followed: it would carry the key elsewhere.
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 its bounds: at most MAX_ENTRIES entries and MAX_STORED_CHARACTERS, the
679
- * oldest exchanges dropped whole, so it always starts at a member's message (a tool result is never
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
- // PostgreSQL's jsonb holds no NUL character: one would fail the write.
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
- // One exchange too big alone: its steps (a call and its results) from the oldest, those that
695
- // proposed no change first; the member's message and the last answer stay.
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 kept entries show, the oldest settled ones dropped
715
- * first past MAX_ACTIONS_BYTES (a waiting one is kept: SLOTLOCK_ASSISTANT_MAX_PENDING bounds them).
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 = [];
@@ -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 (a hold, a reservation, an
4
- * agent's event) must satisfy, and what a search offers. Each is optional; none set is no rule.
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
- * Where the steps fall: this many minutes past each step from local midnight (with a 30-minute
19
- * step, 15 starts bookings on :15 and :45): 0 to one less than the step. Without a step it is
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 (preparation, travel), as its
25
- * leading buffer: the database refuses a booking whose time, or trailing buffer, reaches into
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 (cleaning, handover, travel), as its
31
- * trailing buffer, so the database refuses a booking that would start inside it: 0-1,440. A
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 is claimed (checked in) within this many minutes of its start, or maintenance
39
- * releases it for others (and offers it to the waitlist): 1-1,440. Only bookings made once the
40
- * rule is set are released.
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 lies in time every calendar sync recording coverage for the resource has read within
45
- * the last day (`recordCalendarCoverage`): time a stale or failing sync leaves unproven is
46
- * refused (`uncertain`), not booked over what it may hide. A resource no sync covers is certain.
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 that apply to a resource, merged from its own and its pool's and tenant's defaults
66
- * (each validated when it was set), read leniently: each field is kept when it is valid on its own
67
- * and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
68
- * to a resource's own shorter step, and the offset then applies modulo the step. A merge never
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
- * Default booking rules for a tenant or a pool from untrusted input: booking rules without
77
- * `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
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` that starts on a step in `timezone`, `offsetMinutes` past
87
- * each multiple of the step from local midnight (0 to the step less one). A step that does not
88
- * divide the day starts again at local midnight. Across a clock change it moves forward until the
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` are the resource's
94
- * bookable hours (its own, or the default), read only when the rules enforce them.
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 start moves to a step. `null` when no start in the
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. A slot [s, s + d) keeping buffers occupies
133
- * [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
134
- * [b - after, e + before): the database refuses the same booking.
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 that apply to a resource, merged from its own and its pool's and tenant's defaults
58
- * (each validated when it was set), read leniently: each field is kept when it is valid on its own
59
- * and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
60
- * to a resource's own shorter step, and the offset then applies modulo the step. A merge never
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
- * Default booking rules for a tenant or a pool from untrusted input: booking rules without
92
- * `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
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` that starts on a step in `timezone`, `offsetMinutes` past
127
- * each multiple of the step from local midnight (0 to the step less one). A step that does not
128
- * divide the day starts again at local midnight. Across a clock change it moves forward until the
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` are the resource's
146
- * bookable hours (its own, or the default), read only when the rules enforce them.
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 start moves to a step. `null` when no start in the
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, no slot: move it beyond the window so it cannot fit.
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. A slot [s, s + d) keeping buffers occupies
231
- * [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
232
- * [b - after, e + before): the database refuses the same booking.
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 (arguments, environment, output streams, signals
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 by now, so the process ends on its own. Should a stray handle (a socket or
21
- // timer something else left open) keep it alive, exit anyway rather than hang a container stop.
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
- * The dashboard's settings, or undefined when none is set. Setting any of them means serving the
52
- * dashboard, so the rest are then required: a half-configured sign-in is a startup error.
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 {};