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.
- package/CHANGELOG.md +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/api-keys.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { type SlotlockAgentPrincipal
|
|
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
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
/**
|
|
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
|
|
123
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
|
233
|
-
*
|
|
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 (
|
|
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) =>
|
|
280
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
256
281
|
}
|
|
257
282
|
else {
|
|
258
|
-
|
|
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 {};
|