slotlock 0.2.0 → 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 +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -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 +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- 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.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +213 -9
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
|
|
240
|
-
*
|
|
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 (
|
|
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) =>
|
|
280
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
263
281
|
}
|
|
264
282
|
else {
|
|
265
|
-
|
|
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
|
package/application-api.d.ts
CHANGED
|
@@ -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_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
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,14 +20,27 @@ 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;
|
|
25
29
|
acquireHold: Handler;
|
|
30
|
+
getHold: Handler;
|
|
31
|
+
listHolds: Handler;
|
|
26
32
|
confirmHold: Handler;
|
|
27
33
|
releaseHold: Handler;
|
|
34
|
+
getReservation: Handler;
|
|
35
|
+
listReservations: Handler;
|
|
28
36
|
putReservation: Handler;
|
|
29
37
|
cancelReservation: Handler;
|
|
30
38
|
recordCoverage: Handler;
|
|
39
|
+
listTenantEvents: Handler;
|
|
40
|
+
getTenantEvent: Handler;
|
|
41
|
+
cancelTenantEvent: Handler;
|
|
42
|
+
getTenant: Handler;
|
|
43
|
+
eraseTenant: Handler;
|
|
31
44
|
runMaintenance: Handler;
|
|
32
45
|
}
|
|
33
46
|
export interface SlotlockApplicationOperationDefinition {
|
package/application-api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The application API: what an application, rather than an agent, does over HTTP that the TypeScript
|
|
2
2
|
// store does in process. It creates resources and sets their bookable hours, holds and reserves time,
|
|
3
|
-
// records how much of an external calendar it has read,
|
|
3
|
+
// records how much of an external calendar it has read, reaches any of the tenant's events whichever
|
|
4
|
+
// key wrote them, erases the tenant when allowed, and runs maintenance. Any language can call it.
|
|
4
5
|
//
|
|
5
6
|
// The agent server serves these operations as REST under /v1, beside the agent tools, when it is
|
|
6
7
|
// given an `application` backend and `rest` is on, and describes them in the same OpenAPI document.
|
|
@@ -9,11 +10,14 @@
|
|
|
9
10
|
// This module holds only the contract (names, schemas, routes); it imports nothing from the agent
|
|
10
11
|
// server at runtime. The store-backed implementation is createSlotlockStoreApplicationBackend.
|
|
11
12
|
import { z } from 'zod';
|
|
13
|
+
import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
|
|
14
|
+
import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
|
|
12
15
|
/** The scope an application operation needs, whichever operation it is. */
|
|
13
16
|
export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
|
|
14
17
|
const DATE_TIME = z.string().datetime({ offset: true });
|
|
15
18
|
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
16
19
|
const HOLD_ID = z.string().trim().min(1).max(200);
|
|
20
|
+
const EVENT_ID = z.string().trim().min(1).max(200);
|
|
17
21
|
/** Matches the store's identities: not blank, at most 500 UTF-8 bytes, no control characters. */
|
|
18
22
|
const IDENTITY = z
|
|
19
23
|
.string()
|
|
@@ -38,6 +42,32 @@ const boundedSpan = (value) => {
|
|
|
38
42
|
return span > 0 && span <= MAX_SPAN_MS;
|
|
39
43
|
};
|
|
40
44
|
const SPAN_MESSAGE = { message: 'end must be after start, by at most 3,660 days' };
|
|
45
|
+
/** The longest window one list or busy read covers: the store's calendar horizon. */
|
|
46
|
+
const MAX_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
|
|
47
|
+
const boundedWindow = (value) => {
|
|
48
|
+
const span = Date.parse(value.end) - Date.parse(value.start);
|
|
49
|
+
return span > 0 && span <= MAX_WINDOW_MS;
|
|
50
|
+
};
|
|
51
|
+
const WINDOW_MESSAGE = {
|
|
52
|
+
message: `end must be after start, by at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days`,
|
|
53
|
+
};
|
|
54
|
+
const PAGE_LIMIT = z.number().int().min(1).max(100).default(50);
|
|
55
|
+
/** The `next_cursor` of the page before; opaque. */
|
|
56
|
+
const CURSOR = z.string().min(1).max(500);
|
|
57
|
+
const NEXT_CURSOR = z.string().max(500).nullable();
|
|
58
|
+
const VISIBILITY = z.enum(['shared', 'private']);
|
|
59
|
+
/**
|
|
60
|
+
* A retry identity, as the `Idempotency-Key` request header or this field (the same value, if both
|
|
61
|
+
* are sent): the same key with the same request answers what the first did, and with another
|
|
62
|
+
* request is 409 idempotency_conflict.
|
|
63
|
+
*/
|
|
64
|
+
const IDEMPOTENCY_KEY = IDENTITY.describe('A retry identity, also accepted as the Idempotency-Key header: the same key with the same request answers what the first did; with another request, 409 idempotency_conflict.');
|
|
65
|
+
/** Minutes after the end kept busy as well, for turnaround: at most 30 days. */
|
|
66
|
+
const BUFFER_AFTER_MINUTES = z
|
|
67
|
+
.number()
|
|
68
|
+
.int()
|
|
69
|
+
.min(0)
|
|
70
|
+
.max(30 * 24 * 60);
|
|
41
71
|
const AVAILABILITY_RULE = z
|
|
42
72
|
.object({
|
|
43
73
|
rrule: z.string().min(1).max(500),
|
|
@@ -53,6 +83,10 @@ const RESOURCE = z
|
|
|
53
83
|
timezone: TIMEZONE,
|
|
54
84
|
/** The resource's own weekly hours; `null` when it uses the default. */
|
|
55
85
|
availability: AVAILABILITY_RULES.nullable(),
|
|
86
|
+
/** `private` resources are never shown to agents; fixed when the resource is created. */
|
|
87
|
+
visibility: VISIBILITY,
|
|
88
|
+
/** When it was archived; `null` while it is in use. */
|
|
89
|
+
archived_at: DATE_TIME.nullable(),
|
|
56
90
|
})
|
|
57
91
|
.strict();
|
|
58
92
|
const HOLD = z
|
|
@@ -77,8 +111,20 @@ const RESERVATION = z
|
|
|
77
111
|
source: z.string().max(200).nullable(),
|
|
78
112
|
})
|
|
79
113
|
.strict();
|
|
114
|
+
/**
|
|
115
|
+
* Any of the tenant's events, whichever key wrote it: `id` is the store's id for it, which the
|
|
116
|
+
* tenant-event routes take, `event_ref` the id its owner reads it by, and `owner_ref` who that is
|
|
117
|
+
* (`internal` for the tenant's own code, `agent:<digest>` for an API key).
|
|
118
|
+
*/
|
|
119
|
+
const TENANT_EVENT = SLOTLOCK_EVENT_OUTPUT.extend({
|
|
120
|
+
event_ref: z.string().max(500),
|
|
121
|
+
owner_ref: z.string().max(500),
|
|
122
|
+
}).strict();
|
|
123
|
+
const BUSY_INTERVAL = z.object({ start: DATE_TIME, end: DATE_TIME }).strict();
|
|
80
124
|
const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
|
|
81
125
|
const EXAMPLE_HOLD_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
|
126
|
+
const EXAMPLE_EVENT_ID = '3d6f0a2b-8c4e-4f1a-9b7d-5e2c8a1f4b6d';
|
|
127
|
+
const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-04-01T00:00:00Z' };
|
|
82
128
|
const EXAMPLE_RULES = [
|
|
83
129
|
{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', start_minutes: 540, duration_minutes: 480 },
|
|
84
130
|
];
|
|
@@ -86,10 +132,16 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
86
132
|
{
|
|
87
133
|
name: 'slotlock_create_resource',
|
|
88
134
|
title: 'Create or update a resource',
|
|
89
|
-
description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more.',
|
|
135
|
+
description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more. visibility is shared (the default: agents list and book it) or private (agents never see it), fixed at creation: naming another for an existing resource answers 409 resource_visibility_conflict.',
|
|
90
136
|
method: 'POST',
|
|
91
137
|
path: '/v1/resources',
|
|
92
|
-
input: z
|
|
138
|
+
input: z
|
|
139
|
+
.object({
|
|
140
|
+
external_ref: IDENTITY,
|
|
141
|
+
timezone: TIMEZONE.default('UTC'),
|
|
142
|
+
visibility: VISIBILITY.optional(),
|
|
143
|
+
})
|
|
144
|
+
.strict(),
|
|
93
145
|
output: z.object({ resource: RESOURCE }).strict(),
|
|
94
146
|
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
95
147
|
invoke: 'createResource',
|
|
@@ -98,7 +150,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
98
150
|
{
|
|
99
151
|
name: 'slotlock_get_resource',
|
|
100
152
|
title: 'Get a resource',
|
|
101
|
-
description: 'One resource of the tenant, with its own weekly bookable hours.',
|
|
153
|
+
description: 'One resource of the tenant, archived or not, with its own weekly bookable hours and its visibility.',
|
|
102
154
|
method: 'GET',
|
|
103
155
|
path: '/v1/resources/{resource_id}',
|
|
104
156
|
input: z.object({ resource_id: RESOURCE_ID }).strict(),
|
|
@@ -107,6 +159,57 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
107
159
|
invoke: 'getResource',
|
|
108
160
|
example: { resource_id: EXAMPLE_RESOURCE_ID },
|
|
109
161
|
},
|
|
162
|
+
{
|
|
163
|
+
name: 'slotlock_archive_resource',
|
|
164
|
+
title: 'Archive a resource',
|
|
165
|
+
description: 'Take a resource out of use: no list shows it and it takes no new booking, hold or event (409 resource_archived), while what is on it stays and can still be cancelled or released. Archiving it again keeps the first time.',
|
|
166
|
+
method: 'POST',
|
|
167
|
+
path: '/v1/resources/{resource_id}/archive',
|
|
168
|
+
input: z.object({ resource_id: RESOURCE_ID }).strict(),
|
|
169
|
+
output: z.object({ resource: RESOURCE }).strict(),
|
|
170
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
171
|
+
invoke: 'archiveResource',
|
|
172
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'slotlock_restore_resource',
|
|
176
|
+
title: 'Restore a resource',
|
|
177
|
+
description: 'Put an archived resource back in use. Restoring one in use changes nothing.',
|
|
178
|
+
method: 'POST',
|
|
179
|
+
path: '/v1/resources/{resource_id}/restore',
|
|
180
|
+
input: z.object({ resource_id: RESOURCE_ID }).strict(),
|
|
181
|
+
output: z.object({ resource: RESOURCE }).strict(),
|
|
182
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
183
|
+
invoke: 'restoreResource',
|
|
184
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID },
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'slotlock_delete_resource',
|
|
188
|
+
title: 'Delete a resource',
|
|
189
|
+
description: "Delete a resource with its history (past bookings, coverage, cancelled events), freeing its place under the tenant's resource limit. Refused with 409 resource_in_use while a booking on it has not ended, a hold on it is live, or a calendar event is on it: cancel or move those first. deleted is false when the tenant has no such resource, deleted already, say.",
|
|
190
|
+
method: 'DELETE',
|
|
191
|
+
path: '/v1/resources/{resource_id}',
|
|
192
|
+
input: z.object({ resource_id: RESOURCE_ID }).strict(),
|
|
193
|
+
output: z.object({ deleted: z.boolean() }).strict(),
|
|
194
|
+
risk: { readOnly: false, destructive: true, idempotent: true },
|
|
195
|
+
invoke: 'deleteResource',
|
|
196
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID },
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'slotlock_list_busy',
|
|
200
|
+
title: "List a resource's busy time",
|
|
201
|
+
description: `The time a resource of the tenant, private ones included, is occupied in a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, by start: bookings with their turnaround, live holds, and calendar events. Adjacent intervals are not merged. More than 10,000 answers 400 calendar_work_limit: narrow the window. Free/busy with coverage, for the resources agents see, is slotlock_get_free_busy.`,
|
|
202
|
+
method: 'GET',
|
|
203
|
+
path: '/v1/resources/{resource_id}/busy',
|
|
204
|
+
input: z
|
|
205
|
+
.object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME })
|
|
206
|
+
.strict()
|
|
207
|
+
.refine(boundedWindow, WINDOW_MESSAGE),
|
|
208
|
+
output: z.object({ busy: z.array(BUSY_INTERVAL).max(10_000) }).strict(),
|
|
209
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
210
|
+
invoke: 'listBusy',
|
|
211
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW },
|
|
212
|
+
},
|
|
110
213
|
{
|
|
111
214
|
name: 'slotlock_set_resource_availability',
|
|
112
215
|
title: "Set a resource's bookable hours",
|
|
@@ -134,7 +237,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
134
237
|
{
|
|
135
238
|
name: 'slotlock_acquire_hold',
|
|
136
239
|
title: 'Hold time',
|
|
137
|
-
description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. A taken window answers 409 overlap.',
|
|
240
|
+
description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. buffer_after_minutes keeps the time after it busy as well, and a confirmed hold keeps it. A taken window answers 409 overlap. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the hold the first request made, until that hold would have expired, is released or is confirmed; the same key with another request is 409 idempotency_conflict.',
|
|
138
241
|
method: 'POST',
|
|
139
242
|
path: '/v1/holds',
|
|
140
243
|
input: z
|
|
@@ -148,6 +251,9 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
148
251
|
.min(1)
|
|
149
252
|
.max(7 * 24 * 60 * 60),
|
|
150
253
|
source: SOURCE.optional(),
|
|
254
|
+
idempotency_key: IDEMPOTENCY_KEY.optional(),
|
|
255
|
+
// Last: a generated client's 0.2.0 positional arguments keep their places.
|
|
256
|
+
buffer_after_minutes: BUFFER_AFTER_MINUTES.optional(),
|
|
151
257
|
})
|
|
152
258
|
.strict()
|
|
153
259
|
.refine(boundedSpan, SPAN_MESSAGE),
|
|
@@ -160,12 +266,39 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
160
266
|
end: '2027-03-29T10:00:00Z',
|
|
161
267
|
ttl_seconds: 600,
|
|
162
268
|
source: 'checkout',
|
|
269
|
+
idempotency_key: 'checkout-7f3a9c',
|
|
163
270
|
},
|
|
164
271
|
},
|
|
272
|
+
{
|
|
273
|
+
name: 'slotlock_get_hold',
|
|
274
|
+
title: 'Get a hold',
|
|
275
|
+
description: 'One of the tenant\'s live holds. An expired, confirmed, released or unknown one answers 404 hold_not_found; a confirmed one is a reservation whose reference is the hold\'s id.',
|
|
276
|
+
method: 'GET',
|
|
277
|
+
path: '/v1/holds/{hold_id}',
|
|
278
|
+
input: z.object({ hold_id: HOLD_ID }).strict(),
|
|
279
|
+
output: z.object({ hold: HOLD }).strict(),
|
|
280
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
281
|
+
invoke: 'getHold',
|
|
282
|
+
example: { hold_id: EXAMPLE_HOLD_ID },
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'slotlock_list_holds',
|
|
286
|
+
title: "List a resource's holds",
|
|
287
|
+
description: "The live holds on one of the tenant's resources, by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null.",
|
|
288
|
+
method: 'GET',
|
|
289
|
+
path: '/v1/resources/{resource_id}/holds',
|
|
290
|
+
input: z
|
|
291
|
+
.object({ resource_id: RESOURCE_ID, limit: PAGE_LIMIT, cursor: CURSOR.optional() })
|
|
292
|
+
.strict(),
|
|
293
|
+
output: z.object({ holds: z.array(HOLD).max(100), next_cursor: NEXT_CURSOR }).strict(),
|
|
294
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
295
|
+
invoke: 'listHolds',
|
|
296
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID, limit: 50 },
|
|
297
|
+
},
|
|
165
298
|
{
|
|
166
299
|
name: 'slotlock_confirm_hold',
|
|
167
300
|
title: 'Confirm a hold',
|
|
168
|
-
description:
|
|
301
|
+
description: "Turn an unexpired hold into a confirmed reservation, whose reference is the hold's id at revision 1: move or cancel it with the reservation routes by that reference (DELETE /v1/reservations/{hold_id}?revision=2 cancels it). Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found.",
|
|
169
302
|
method: 'POST',
|
|
170
303
|
path: '/v1/holds/{hold_id}/confirm',
|
|
171
304
|
input: z.object({ hold_id: HOLD_ID }).strict(),
|
|
@@ -186,6 +319,41 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
186
319
|
invoke: 'releaseHold',
|
|
187
320
|
example: { hold_id: EXAMPLE_HOLD_ID },
|
|
188
321
|
},
|
|
322
|
+
{
|
|
323
|
+
name: 'slotlock_get_reservation',
|
|
324
|
+
title: 'Get a reservation',
|
|
325
|
+
description: "The tenant's booking with this reference as it now is, at its current revision: move it at that revision plus 1. A cancelled or unknown reference, or a calendar event's time, answers 404 reservation_not_found.",
|
|
326
|
+
method: 'GET',
|
|
327
|
+
path: '/v1/reservations/{reservation_ref}',
|
|
328
|
+
input: z.object({ reservation_ref: IDENTITY }).strict(),
|
|
329
|
+
output: z.object({ reservation: RESERVATION }).strict(),
|
|
330
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
331
|
+
invoke: 'getReservation',
|
|
332
|
+
example: { reservation_ref: 'booking-BK-42' },
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: 'slotlock_list_reservations',
|
|
336
|
+
title: "List a resource's reservations",
|
|
337
|
+
description: `The tenant's bookings on one resource whose time, turnaround included, meets a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null. Calendar events are not bookings; slotlock_list_tenant_events lists them.`,
|
|
338
|
+
method: 'GET',
|
|
339
|
+
path: '/v1/resources/{resource_id}/reservations',
|
|
340
|
+
input: z
|
|
341
|
+
.object({
|
|
342
|
+
resource_id: RESOURCE_ID,
|
|
343
|
+
start: DATE_TIME,
|
|
344
|
+
end: DATE_TIME,
|
|
345
|
+
limit: PAGE_LIMIT,
|
|
346
|
+
cursor: CURSOR.optional(),
|
|
347
|
+
})
|
|
348
|
+
.strict()
|
|
349
|
+
.refine(boundedWindow, WINDOW_MESSAGE),
|
|
350
|
+
output: z
|
|
351
|
+
.object({ reservations: z.array(RESERVATION).max(100), next_cursor: NEXT_CURSOR })
|
|
352
|
+
.strict(),
|
|
353
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
354
|
+
invoke: 'listReservations',
|
|
355
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
|
|
356
|
+
},
|
|
189
357
|
{
|
|
190
358
|
name: 'slotlock_put_reservation',
|
|
191
359
|
title: 'Book or move a reservation',
|
|
@@ -198,12 +366,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
198
366
|
resource_id: RESOURCE_ID,
|
|
199
367
|
start: DATE_TIME,
|
|
200
368
|
end: DATE_TIME,
|
|
201
|
-
buffer_after_minutes:
|
|
202
|
-
.number()
|
|
203
|
-
.int()
|
|
204
|
-
.min(0)
|
|
205
|
-
.max(30 * 24 * 60)
|
|
206
|
-
.default(0),
|
|
369
|
+
buffer_after_minutes: BUFFER_AFTER_MINUTES.default(0),
|
|
207
370
|
revision: REVISION,
|
|
208
371
|
source: SOURCE.optional(),
|
|
209
372
|
})
|
|
@@ -260,6 +423,105 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
260
423
|
revision: 7,
|
|
261
424
|
},
|
|
262
425
|
},
|
|
426
|
+
{
|
|
427
|
+
name: 'slotlock_list_tenant_events',
|
|
428
|
+
title: "List a resource's events, every key's",
|
|
429
|
+
description: `The calendar events on one of the tenant's resources with an occurrence in a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, whichever key wrote them (a revoked one's included), by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null.`,
|
|
430
|
+
method: 'GET',
|
|
431
|
+
path: '/v1/tenant/events',
|
|
432
|
+
input: z
|
|
433
|
+
.object({
|
|
434
|
+
resource_id: RESOURCE_ID,
|
|
435
|
+
start: DATE_TIME,
|
|
436
|
+
end: DATE_TIME,
|
|
437
|
+
limit: PAGE_LIMIT,
|
|
438
|
+
cursor: CURSOR.optional(),
|
|
439
|
+
})
|
|
440
|
+
.strict()
|
|
441
|
+
.refine(boundedWindow, WINDOW_MESSAGE),
|
|
442
|
+
output: z
|
|
443
|
+
.object({ events: z.array(TENANT_EVENT).max(100), next_cursor: NEXT_CURSOR })
|
|
444
|
+
.strict(),
|
|
445
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
446
|
+
invoke: 'listTenantEvents',
|
|
447
|
+
example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: 'slotlock_get_tenant_event',
|
|
451
|
+
title: 'Get any event of the tenant',
|
|
452
|
+
description: "One of the tenant's events by its id (the id slotlock_list_tenant_events gives), whichever key wrote it. A cancelled or unknown one answers 404 event_not_found.",
|
|
453
|
+
method: 'GET',
|
|
454
|
+
path: '/v1/tenant/events/{event_id}',
|
|
455
|
+
input: z.object({ event_id: EVENT_ID }).strict(),
|
|
456
|
+
output: z.object({ event: TENANT_EVENT }).strict(),
|
|
457
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
458
|
+
invoke: 'getTenantEvent',
|
|
459
|
+
example: { event_id: EXAMPLE_EVENT_ID },
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'slotlock_cancel_tenant_event',
|
|
463
|
+
title: 'Cancel any event of the tenant',
|
|
464
|
+
description: "Cancel one of the tenant's events by its id, whichever key wrote it (a revoked key's included, without charging its quota), at the revision you last read (expected_revision). Its owner cannot create it again. With expected_resource_id, an event since moved to another resource answers 404 event_not_found. Needs an idempotency key (the Idempotency-Key header or idempotency_key): a retry with it answers the first cancellation (replayed: true).",
|
|
465
|
+
method: 'DELETE',
|
|
466
|
+
path: '/v1/tenant/events/{event_id}',
|
|
467
|
+
input: z
|
|
468
|
+
.object({
|
|
469
|
+
event_id: EVENT_ID,
|
|
470
|
+
expected_revision: REVISION,
|
|
471
|
+
expected_resource_id: RESOURCE_ID.optional(),
|
|
472
|
+
// Required, so a client always sends it; the Idempotency-Key header may stand in for
|
|
473
|
+
// it, since the server reads the header into it before the schema checks the request.
|
|
474
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
475
|
+
})
|
|
476
|
+
.strict(),
|
|
477
|
+
output: z
|
|
478
|
+
.object({
|
|
479
|
+
event_id: EVENT_ID,
|
|
480
|
+
revision: REVISION,
|
|
481
|
+
cancelled: z.literal(true),
|
|
482
|
+
replayed: z.boolean(),
|
|
483
|
+
})
|
|
484
|
+
.strict(),
|
|
485
|
+
risk: { readOnly: false, destructive: true, idempotent: true },
|
|
486
|
+
invoke: 'cancelTenantEvent',
|
|
487
|
+
example: {
|
|
488
|
+
event_id: EXAMPLE_EVENT_ID,
|
|
489
|
+
expected_revision: 2,
|
|
490
|
+
idempotency_key: 'cancel-3d6f0a2b',
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: 'slotlock_get_tenant',
|
|
495
|
+
title: 'Get the tenant',
|
|
496
|
+
description: "The key's tenant: its reference, and whether it is suspended. A suspended tenant may read and writes nothing (403 tenant_suspended); only the server's operator suspends a tenant or lifts a suspension (slotlock tenant suspend).",
|
|
497
|
+
method: 'GET',
|
|
498
|
+
path: '/v1/tenant',
|
|
499
|
+
input: z.object({}).strict(),
|
|
500
|
+
output: z
|
|
501
|
+
.object({ tenant: z.object({ ref: IDENTITY, suspended: z.boolean() }).strict() })
|
|
502
|
+
.strict(),
|
|
503
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
504
|
+
invoke: 'getTenant',
|
|
505
|
+
example: {},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
name: 'slotlock_erase_tenant',
|
|
509
|
+
title: 'Erase the tenant',
|
|
510
|
+
description: "Delete everything the key's tenant has, at once and for good: resources, holds, bookings, events, coverage and every API key, this one included. confirm_tenant_ref must be the tenant's reference (slotlock_get_tenant), or it answers 400 invalid_confirmation. Off unless the server allows it (403 tenant_erasure_disabled; slotlock serve does not: its operator runs slotlock tenant erase). A suspended tenant cannot erase itself (403 tenant_suspended). Erasing again deletes nothing.",
|
|
511
|
+
method: 'POST',
|
|
512
|
+
path: '/v1/tenant/erase',
|
|
513
|
+
input: z.object({ confirm_tenant_ref: IDENTITY }).strict(),
|
|
514
|
+
output: z
|
|
515
|
+
.object({
|
|
516
|
+
total: z.number().int().min(0),
|
|
517
|
+
/** Rows deleted, by table. */
|
|
518
|
+
rows: z.record(z.string(), z.number().int().min(0)),
|
|
519
|
+
})
|
|
520
|
+
.strict(),
|
|
521
|
+
risk: { readOnly: false, destructive: true, idempotent: true },
|
|
522
|
+
invoke: 'eraseTenant',
|
|
523
|
+
example: { confirm_tenant_ref: 'fleet-ops' },
|
|
524
|
+
},
|
|
263
525
|
{
|
|
264
526
|
name: 'slotlock_run_maintenance',
|
|
265
527
|
title: 'Run maintenance',
|
package/config.d.ts
CHANGED
|
@@ -33,3 +33,96 @@ export interface SlotlockServeDashboardConfig {
|
|
|
33
33
|
* dashboard, so the rest are then required: a half-configured sign-in is a startup error.
|
|
34
34
|
*/
|
|
35
35
|
export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDashboardConfig | undefined;
|
|
36
|
+
/** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
|
|
37
|
+
export interface SlotlockRate {
|
|
38
|
+
perSecond: number;
|
|
39
|
+
burst: number;
|
|
40
|
+
}
|
|
41
|
+
/** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
|
|
42
|
+
export declare const SLOTLOCK_RATE_NAMES: readonly ["address", "key_read", "key_write", "tenant_read", "tenant_write", "global_read", "global_write", "app_read", "app_write", "sign_in"];
|
|
43
|
+
export type SlotlockRateName = (typeof SLOTLOCK_RATE_NAMES)[number];
|
|
44
|
+
/** The counts `slotlock serve` caps (SLOTLOCK_LIMITS), with the range each may be set to. */
|
|
45
|
+
declare const CAP_RANGES: Readonly<{
|
|
46
|
+
readonly tenant_series_writes: readonly [1, 100];
|
|
47
|
+
readonly series_writes: readonly [1, 100];
|
|
48
|
+
readonly maintenance_runs: readonly [1, 100];
|
|
49
|
+
readonly subscriptions: readonly [1, 10000];
|
|
50
|
+
readonly tenant_subscriptions: readonly [1, 10000];
|
|
51
|
+
readonly key_subscriptions: readonly [1, 64];
|
|
52
|
+
readonly key_lookups: readonly [1, 8];
|
|
53
|
+
readonly key_lookup_queue: readonly [0, 10000];
|
|
54
|
+
readonly tenant_transactions: readonly [1, 10];
|
|
55
|
+
readonly tenant_holds: readonly [1, 10000000];
|
|
56
|
+
readonly tenant_reservations: readonly [1, 10000000];
|
|
57
|
+
readonly tenant_events: readonly [1, 10000000];
|
|
58
|
+
readonly tenant_coverage: readonly [1, 10000000];
|
|
59
|
+
}>;
|
|
60
|
+
export type SlotlockCapName = keyof typeof CAP_RANGES;
|
|
61
|
+
export declare const SLOTLOCK_CAP_NAMES: readonly ("key_lookup_queue" | "key_lookups" | "key_subscriptions" | "maintenance_runs" | "series_writes" | "subscriptions" | "tenant_coverage" | "tenant_events" | "tenant_holds" | "tenant_reservations" | "tenant_series_writes" | "tenant_subscriptions" | "tenant_transactions")[];
|
|
62
|
+
export interface SlotlockLimits {
|
|
63
|
+
/** Each rate, or null where SLOTLOCK_LIMITS turns it off. */
|
|
64
|
+
rates: Readonly<Record<SlotlockRateName, SlotlockRate | null>>;
|
|
65
|
+
caps: Readonly<Record<SlotlockCapName, number>>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What `slotlock serve` allows unless SLOTLOCK_LIMITS says otherwise, sized for one public server on
|
|
69
|
+
* one CPU (the 2026-10-24 readiness audit measured the database at about 900 writes a second):
|
|
70
|
+
* - `address`: every request from one client address, before any credential is looked up.
|
|
71
|
+
* - `key_*`, `tenant_*`, `global_*`: an authenticated request takes a read token from its key, its
|
|
72
|
+
* tenant and the server; a write (a REST method other than GET or HEAD, or a write tool) takes a
|
|
73
|
+
* write token from each as well.
|
|
74
|
+
* - `app_*`: a request to the application API (application-api.ts) from an admin key takes its
|
|
75
|
+
* read and write tokens from its tenant's application buckets instead of the key and tenant ones,
|
|
76
|
+
* so the application and the tenant's agents cannot use up each other's budget; one from any
|
|
77
|
+
* other key takes them from that key's own. `global_*` counts it as well.
|
|
78
|
+
* - `sign_in`: dashboard sign-ins and their callbacks from one client network: an IPv4 address,
|
|
79
|
+
* or an IPv6 client's /48 (slotlockClientNetwork in rate-limit.ts), as the dashboard Worker
|
|
80
|
+
* counts them.
|
|
81
|
+
* - `tenant_series_writes` and `series_writes`: writes that may expand a recurring series (a
|
|
82
|
+
* recurring create, any update) running at once in a tenant and in each server process.
|
|
83
|
+
* - `maintenance_runs`: the application API's maintenance runs at once in each server process, one
|
|
84
|
+
* a tenant, in slots of their own. A freed slot of either kind rests for as long as the event
|
|
85
|
+
* loop was busy while its write held it, up to a second, then goes to the tenant with the fewest
|
|
86
|
+
* writes in flight and, among those, the least use lately. That keeps a tenant sending one write
|
|
87
|
+
* at a time from waiting behind tenants with more, not behind many tenants with one each
|
|
88
|
+
* (rate-limit.ts).
|
|
89
|
+
* - `subscriptions`: MCP live-update streams the server holds open. When it is full, a tenant
|
|
90
|
+
* holding fewer than the tenant holding most, less one, ends that tenant's oldest stream.
|
|
91
|
+
* - `tenant_subscriptions`: of those, how many one tenant may hold.
|
|
92
|
+
* - `key_subscriptions`: of those, how many one key (or the server token) may hold, and always
|
|
93
|
+
* fewer than its tenant's, so one key cannot lock the tenant's other keys out
|
|
94
|
+
* (slotlockKeySubscriptions).
|
|
95
|
+
* - `key_lookups` and `key_lookup_queue`: API key lookups running at once and waiting.
|
|
96
|
+
* - `tenant_transactions`: store transactions one tenant runs at once on the server's pool of 10
|
|
97
|
+
* connections; the rest wait their turn in the process, so a tenant's writes waiting on its own
|
|
98
|
+
* locks leave the other connections to other tenants and key lookups. A server with one tenant
|
|
99
|
+
* can set 10.
|
|
100
|
+
* - `tenant_holds`, `tenant_reservations`, `tenant_events` and `tenant_coverage`: what one tenant
|
|
101
|
+
* may store, counted as the store's tenant limits count it (SlotlockTenantLimits), whichever key,
|
|
102
|
+
* agent or application writes it. Every write surface of the server shares them, so no admin
|
|
103
|
+
* key can grow the shared database without end; a write past one answers 409 with its
|
|
104
|
+
* `*_limit_reached` code.
|
|
105
|
+
*/
|
|
106
|
+
export declare const SLOTLOCK_DEFAULT_LIMITS: SlotlockLimits;
|
|
107
|
+
/**
|
|
108
|
+
* The live-update streams one key may hold open: `key_subscriptions`, but at least one fewer than
|
|
109
|
+
* its tenant may (unless the tenant may hold only one), so a key at its own limit leaves its
|
|
110
|
+
* tenant's other keys a stream however the two caps are set.
|
|
111
|
+
*/
|
|
112
|
+
export declare function slotlockKeySubscriptions(caps: Readonly<Record<SlotlockCapName, number>>): number;
|
|
113
|
+
/** A rate as SLOTLOCK_LIMITS and the `listening` log line write it: `<count>/<s|m|h>:<burst>`. */
|
|
114
|
+
export declare function formatSlotlockRate(rate: SlotlockRate | null): string;
|
|
115
|
+
/**
|
|
116
|
+
* SLOTLOCK_LIMITS: comma-separated `name=value` overrides of SLOTLOCK_DEFAULT_LIMITS. A rate is
|
|
117
|
+
* `<count>/<s|m|h>`, optionally `:<burst>` (default: the count), or `off`; a cap is a whole number.
|
|
118
|
+
* For example `tenant_write=2/s:10,global_write=30/s,address=off`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function readLimits(env: SlotlockEnv): SlotlockLimits;
|
|
121
|
+
/**
|
|
122
|
+
* SLOTLOCK_TRUSTED_PROXY_HEADER: the request field holding the client's address, set (and
|
|
123
|
+
* overwritten) by a proxy every request passes through, such as `CF-Connecting-IP` behind
|
|
124
|
+
* Cloudflare. Unset, the client address is the connection's. A client can send any field, so set it
|
|
125
|
+
* only when nothing reaches the server except through that proxy.
|
|
126
|
+
*/
|
|
127
|
+
export declare function readTrustedProxyHeader(env: SlotlockEnv): string | undefined;
|
|
128
|
+
export {};
|