slotlock 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/api-keys.d.ts CHANGED
@@ -117,7 +117,10 @@ export declare function createSlotlockApiKeyStore(sql: SlotlockSql): SlotlockApi
117
117
  export interface SlotlockApiKeyAuthenticatorOptions {
118
118
  /** Key lookups that may run at once, default 2: at most this many connections serve them. */
119
119
  maxConcurrentLookups?: number;
120
- /** Lookups that may wait for one, default 256. A request beyond that is anonymous at once. */
120
+ /**
121
+ * Lookups that may wait for one, default 256, and as many again for keys that recently
122
+ * authenticated. A request beyond that is refused as busy at once.
123
+ */
121
124
  maxWaitingLookups?: number;
122
125
  }
123
126
  /**
@@ -126,7 +129,12 @@ export interface SlotlockApiKeyAuthenticatorOptions {
126
129
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
127
130
  *
128
131
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
129
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
130
- * the pool stays free for authenticated work. Rate-limit a public server in front of it as well.
132
+ * most `maxConcurrentLookups` connections, and past `maxWaitingLookups` a request is refused with a
133
+ * `SlotlockAgentOperationError` (`server_busy`, 503) rather than answered anonymous, which would
134
+ * tell a client holding a valid key that its key is bad. A key that authenticated in the last 15
135
+ * minutes waits in a queue of its own that is served first, so made-up keys, which never
136
+ * authenticate, cannot keep it waiting. Every request is still looked up, so a revoked key stops
137
+ * at once. Rate-limit a public server in front of it as well (`slotlock serve` does, per client
138
+ * address, before any lookup).
131
139
  */
132
140
  export declare function createSlotlockApiKeyAuthenticator(keys: Pick<SlotlockApiKeyStore, 'authenticate'>, options?: SlotlockApiKeyAuthenticatorOptions): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
package/api-keys.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // cannot reach another tenant's keys, read a digest, or bring back a revoked, rotated-out or erased
9
9
  // key.
10
10
  import { createHash, randomBytes } from 'node:crypto';
11
+ import { SlotlockAgentOperationError, } from './agent-server.js';
11
12
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
12
13
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
13
14
  /**
@@ -41,6 +42,10 @@ const DAY_MS = 86_400_000;
41
42
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
43
  const DEFAULT_MAX_CONCURRENT_LOOKUPS = 2;
43
44
  const DEFAULT_MAX_WAITING_LOOKUPS = 256;
45
+ /** How long a key that authenticated keeps its place ahead of unknown keys in the lookup queue. */
46
+ const RECENTLY_VALID_MS = 15 * 60_000;
47
+ /** Keys the authenticator remembers as recently valid, by digest; past this the oldest go first. */
48
+ const MAX_RECENTLY_VALID = 10_000;
44
49
  function invalid(code, message) {
45
50
  return Object.assign(new Error(message), { code });
46
51
  }
@@ -230,14 +235,21 @@ export function createSlotlockApiKeyStore(sql) {
230
235
  },
231
236
  };
232
237
  }
238
+ /** What the authenticator throws when its lookup queue is full: retry later, the key may be fine. */
239
+ const LOOKUP_BUSY = 'server_busy';
233
240
  /**
234
241
  * Bearer authentication by API key: a request carrying an active key is that key's principal,
235
242
  * `api_key:<id>` in the key's own tenant with its scopes, which the agent server enforces before
236
243
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
237
244
  *
238
245
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
239
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
240
- * the pool stays free for authenticated work. Rate-limit a public server in front of it as well.
246
+ * most `maxConcurrentLookups` connections, and past `maxWaitingLookups` a request is refused with a
247
+ * `SlotlockAgentOperationError` (`server_busy`, 503) rather than answered anonymous, which would
248
+ * tell a client holding a valid key that its key is bad. A key that authenticated in the last 15
249
+ * minutes waits in a queue of its own that is served first, so made-up keys, which never
250
+ * authenticate, cannot keep it waiting. Every request is still looked up, so a revoked key stops
251
+ * at once. Rate-limit a public server in front of it as well (`slotlock serve` does, per client
252
+ * address, before any lookup).
241
253
  */
242
254
  export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
243
255
  const maxActive = options.maxConcurrentLookups ?? DEFAULT_MAX_CONCURRENT_LOOKUPS;
@@ -250,26 +262,37 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
250
262
  }
251
263
  let active = 0;
252
264
  const waiting = [];
265
+ const waitingKnown = [];
266
+ /** Digests of keys that recently authenticated, oldest first, with when they stop counting. */
267
+ const recentlyValid = new Map();
253
268
  return async (request) => {
254
269
  const presented = slotlockBearerCredential(request);
255
270
  if (presented === undefined || !isSlotlockApiKey(presented))
256
271
  return null;
272
+ const digest = slotlockApiKeyDigest(presented).toString('hex');
273
+ const known = (recentlyValid.get(digest) ?? 0) > Date.now();
274
+ const queue = known ? waitingKnown : waiting;
257
275
  if (active < maxActive) {
258
276
  active += 1;
259
277
  }
260
- else if (waiting.length < maxWaiting) {
278
+ else if (queue.length < maxWaiting) {
261
279
  // A finishing lookup hands its slot straight to the next waiter, so `active` is unchanged.
262
- await new Promise((resolve) => waiting.push(resolve));
280
+ await new Promise((resolve) => queue.push(resolve));
263
281
  }
264
282
  else {
265
- return null;
283
+ throw new SlotlockAgentOperationError(LOOKUP_BUSY, 503);
266
284
  }
267
285
  try {
268
286
  if (request.signal.aborted)
269
287
  return null;
270
288
  const identity = await keys.authenticate(presented);
289
+ recentlyValid.delete(digest);
271
290
  if (!identity)
272
291
  return null;
292
+ recentlyValid.set(digest, Date.now() + RECENTLY_VALID_MS);
293
+ if (recentlyValid.size > MAX_RECENTLY_VALID) {
294
+ recentlyValid.delete(recentlyValid.keys().next().value);
295
+ }
273
296
  return {
274
297
  subject: `api_key:${identity.id}`,
275
298
  tenantRef: identity.tenantRef,
@@ -277,7 +300,7 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
277
300
  };
278
301
  }
279
302
  finally {
280
- const next = waiting.shift();
303
+ const next = waitingKnown.shift() ?? waiting.shift();
281
304
  if (next)
282
305
  next();
283
306
  else
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { SlotlockAgentPrincipal } from './agent-server.js';
3
3
  import type { SlotlockTraceContext } from './mcp-modern.js';
4
- export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_acquire_hold' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_record_coverage' | 'slotlock_run_maintenance';
4
+ export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_list_pools' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook';
5
5
  /** The scope an application operation needs, whichever operation it is. */
6
6
  export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
7
7
  export interface SlotlockApplicationInvocationContext {
@@ -20,15 +20,49 @@ type Handler = (context: SlotlockApplicationInvocationContext, input: Record<str
20
20
  export interface SlotlockApplicationBackend {
21
21
  createResource: Handler;
22
22
  getResource: Handler;
23
+ archiveResource: Handler;
24
+ restoreResource: Handler;
25
+ deleteResource: Handler;
26
+ listBusy: Handler;
23
27
  setResourceAvailability: Handler;
24
28
  setTenantAvailability: Handler;
29
+ exportResourceAvailability: Handler;
30
+ importResourceAvailability: Handler;
31
+ setResourceBookingRules: Handler;
32
+ setResourcePool: Handler;
33
+ listPools: Handler;
34
+ getResourceHistory: Handler;
35
+ getCoverage: Handler;
36
+ recordCoverageFailure: Handler;
37
+ deleteCoverage: Handler;
38
+ getReservationHistory: Handler;
39
+ getTenantEventHistory: Handler;
25
40
  acquireHold: Handler;
41
+ getHold: Handler;
42
+ listHolds: Handler;
26
43
  confirmHold: Handler;
27
44
  releaseHold: Handler;
45
+ getReservation: Handler;
46
+ listReservations: Handler;
28
47
  putReservation: Handler;
29
48
  cancelReservation: Handler;
49
+ checkInReservation: Handler;
50
+ joinWaitlist: Handler;
51
+ listWaitlist: Handler;
52
+ leaveWaitlist: Handler;
30
53
  recordCoverage: Handler;
54
+ listTenantEvents: Handler;
55
+ getTenantEvent: Handler;
56
+ exportTenantEvent: Handler;
57
+ cancelTenantEvent: Handler;
58
+ getTenant: Handler;
59
+ eraseTenant: Handler;
31
60
  runMaintenance: Handler;
61
+ listChanges: Handler;
62
+ createWebhook: Handler;
63
+ listWebhooks: Handler;
64
+ deleteWebhook: Handler;
65
+ enableWebhook: Handler;
32
66
  }
33
67
  export interface SlotlockApplicationOperationDefinition {
34
68
  name: SlotlockApplicationOperation;