slotlock 0.1.1 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
package/api-keys.d.ts CHANGED
@@ -1,10 +1,17 @@
1
- import { type SlotlockAgentPrincipal, type SlotlockAgentScope } from './agent-server.js';
1
+ import { type SlotlockAgentPrincipal } from './agent-server.js';
2
2
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
3
3
  import type { SlotlockSql } from './store.js';
4
4
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
5
- /** `read` covers the operations that only look; `write` the ones that change a calendar. */
6
- export declare const SLOTLOCK_API_KEY_SCOPES: readonly ["read", "write"];
7
- export type SlotlockApiKeyScope = SlotlockAgentScope;
5
+ /**
6
+ * `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
7
+ * the application API (application-api.ts: resources, bookable hours, holds, reservations, coverage
8
+ * and maintenance), for an application's backend rather than an agent. A key's scopes are stored in
9
+ * this order.
10
+ */
11
+ export declare const SLOTLOCK_API_KEY_SCOPES: readonly ["read", "write", "admin"];
12
+ /** What a key gets unless it asks for more: what an agent needs. `admin` is never a default. */
13
+ export declare const SLOTLOCK_API_KEY_DEFAULT_SCOPES: readonly ["read", "write"];
14
+ export type SlotlockApiKeyScope = (typeof SLOTLOCK_API_KEY_SCOPES)[number];
8
15
  /** Every key starts with this, so a person or a secret scanner can tell what it is. */
9
16
  export declare const SLOTLOCK_API_KEY_PREFIX = "slk_";
10
17
  /** The longest a key may live: ten years. A key without an expiry lives until it is revoked. */
@@ -110,7 +117,10 @@ export declare function createSlotlockApiKeyStore(sql: SlotlockSql): SlotlockApi
110
117
  export interface SlotlockApiKeyAuthenticatorOptions {
111
118
  /** Key lookups that may run at once, default 2: at most this many connections serve them. */
112
119
  maxConcurrentLookups?: number;
113
- /** 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
+ */
114
124
  maxWaitingLookups?: number;
115
125
  }
116
126
  /**
@@ -119,7 +129,12 @@ export interface SlotlockApiKeyAuthenticatorOptions {
119
129
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
120
130
  *
121
131
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
122
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
123
- * 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).
124
139
  */
125
140
  export declare function createSlotlockApiKeyAuthenticator(keys: Pick<SlotlockApiKeyStore, 'authenticate'>, options?: SlotlockApiKeyAuthenticatorOptions): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
package/api-keys.js CHANGED
@@ -8,10 +8,18 @@
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
- /** `read` covers the operations that only look; `write` the ones that change a calendar. */
14
- export const SLOTLOCK_API_KEY_SCOPES = Object.freeze(['read', 'write']);
14
+ /**
15
+ * `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
16
+ * the application API (application-api.ts: resources, bookable hours, holds, reservations, coverage
17
+ * and maintenance), for an application's backend rather than an agent. A key's scopes are stored in
18
+ * this order.
19
+ */
20
+ export const SLOTLOCK_API_KEY_SCOPES = Object.freeze(['read', 'write', 'admin']);
21
+ /** What a key gets unless it asks for more: what an agent needs. `admin` is never a default. */
22
+ export const SLOTLOCK_API_KEY_DEFAULT_SCOPES = Object.freeze(['read', 'write']);
15
23
  /** Every key starts with this, so a person or a secret scanner can tell what it is. */
16
24
  export const SLOTLOCK_API_KEY_PREFIX = 'slk_';
17
25
  /** The longest a key may live: ten years. A key without an expiry lives until it is revoked. */
@@ -34,6 +42,10 @@ const DAY_MS = 86_400_000;
34
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;
35
43
  const DEFAULT_MAX_CONCURRENT_LOOKUPS = 2;
36
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;
37
49
  function invalid(code, message) {
38
50
  return Object.assign(new Error(message), { code });
39
51
  }
@@ -223,14 +235,21 @@ export function createSlotlockApiKeyStore(sql) {
223
235
  },
224
236
  };
225
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';
226
240
  /**
227
241
  * Bearer authentication by API key: a request carrying an active key is that key's principal,
228
242
  * `api_key:<id>` in the key's own tenant with its scopes, which the agent server enforces before
229
243
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
230
244
  *
231
245
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
232
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
233
- * 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).
234
253
  */
235
254
  export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
236
255
  const maxActive = options.maxConcurrentLookups ?? DEFAULT_MAX_CONCURRENT_LOOKUPS;
@@ -243,26 +262,37 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
243
262
  }
244
263
  let active = 0;
245
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();
246
268
  return async (request) => {
247
269
  const presented = slotlockBearerCredential(request);
248
270
  if (presented === undefined || !isSlotlockApiKey(presented))
249
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;
250
275
  if (active < maxActive) {
251
276
  active += 1;
252
277
  }
253
- else if (waiting.length < maxWaiting) {
278
+ else if (queue.length < maxWaiting) {
254
279
  // A finishing lookup hands its slot straight to the next waiter, so `active` is unchanged.
255
- await new Promise((resolve) => waiting.push(resolve));
280
+ await new Promise((resolve) => queue.push(resolve));
256
281
  }
257
282
  else {
258
- return null;
283
+ throw new SlotlockAgentOperationError(LOOKUP_BUSY, 503);
259
284
  }
260
285
  try {
261
286
  if (request.signal.aborted)
262
287
  return null;
263
288
  const identity = await keys.authenticate(presented);
289
+ recentlyValid.delete(digest);
264
290
  if (!identity)
265
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
+ }
266
296
  return {
267
297
  subject: `api_key:${identity.id}`,
268
298
  tenantRef: identity.tenantRef,
@@ -270,7 +300,7 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
270
300
  };
271
301
  }
272
302
  finally {
273
- const next = waiting.shift();
303
+ const next = waitingKnown.shift() ?? waiting.shift();
274
304
  if (next)
275
305
  next();
276
306
  else
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import type { SlotlockAgentPrincipal } from './agent-server.js';
3
+ import type { SlotlockTraceContext } from './mcp-modern.js';
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_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_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance';
5
+ /** The scope an application operation needs, whichever operation it is. */
6
+ export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
7
+ export interface SlotlockApplicationInvocationContext {
8
+ principal: SlotlockAgentPrincipal;
9
+ operation: SlotlockApplicationOperation;
10
+ signal: AbortSignal;
11
+ /** W3C trace context the caller sent, already validated. */
12
+ trace?: SlotlockTraceContext;
13
+ }
14
+ type Handler = (context: SlotlockApplicationInvocationContext, input: Record<string, unknown>) => Promise<Record<string, unknown>>;
15
+ /**
16
+ * What serves the application operations. Every method receives the input its operation's schema
17
+ * accepted, and returns what its output schema describes; a refusal is a thrown
18
+ * `SlotlockAgentOperationError`. The tenant is the principal's, never an argument.
19
+ */
20
+ export interface SlotlockApplicationBackend {
21
+ createResource: Handler;
22
+ getResource: Handler;
23
+ archiveResource: Handler;
24
+ restoreResource: Handler;
25
+ deleteResource: Handler;
26
+ listBusy: Handler;
27
+ setResourceAvailability: Handler;
28
+ setTenantAvailability: Handler;
29
+ acquireHold: Handler;
30
+ getHold: Handler;
31
+ listHolds: Handler;
32
+ confirmHold: Handler;
33
+ releaseHold: Handler;
34
+ getReservation: Handler;
35
+ listReservations: Handler;
36
+ putReservation: Handler;
37
+ cancelReservation: Handler;
38
+ recordCoverage: Handler;
39
+ listTenantEvents: Handler;
40
+ getTenantEvent: Handler;
41
+ cancelTenantEvent: Handler;
42
+ getTenant: Handler;
43
+ eraseTenant: Handler;
44
+ runMaintenance: Handler;
45
+ }
46
+ export interface SlotlockApplicationOperationDefinition {
47
+ name: SlotlockApplicationOperation;
48
+ title: string;
49
+ description: string;
50
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
51
+ /** Relative to the server's base URL; `{name}` is one path segment, an input of that name. */
52
+ path: string;
53
+ input: z.ZodType<Record<string, unknown>>;
54
+ output: z.ZodType<Record<string, unknown>>;
55
+ risk: {
56
+ readOnly: boolean;
57
+ destructive: boolean;
58
+ idempotent: boolean;
59
+ };
60
+ invoke: keyof SlotlockApplicationBackend;
61
+ /** Valid input, path parameters included (a test runs each against the schema). */
62
+ example: Record<string, unknown>;
63
+ }
64
+ export declare const SLOTLOCK_APPLICATION_OPERATIONS: readonly SlotlockApplicationOperationDefinition[];
65
+ export declare function isSlotlockApplicationOperation(name: unknown): name is SlotlockApplicationOperation;
66
+ export declare function slotlockApplicationOperation(name: SlotlockApplicationOperation): SlotlockApplicationOperationDefinition;
67
+ export {};