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.
Files changed (51) hide show
  1. package/CHANGELOG.md +330 -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 +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
package/rate-limit.js ADDED
@@ -0,0 +1,718 @@
1
+ // Request limits for `slotlock serve`, a public multi-tenant server whose only rate limit this is:
2
+ // token buckets per client address (counted before any credential is looked up), per API key, per
3
+ // tenant and for the whole server, with reads and writes counted apart; one key lookup per request,
4
+ // answered busy rather than anonymous when the lookup queue is full; caps on writes that may expand
5
+ // a recurring series, and on maintenance runs, running at once; and counts of what was refused,
6
+ // logged with each client as a pseudonym. A process keeps its counts in memory, so each instance of
7
+ // a scaled-out server limits alone.
8
+ //
9
+ // Refusals carry Retry-After and the RateLimit and RateLimit-Policy fields of
10
+ // draft-ietf-httpapi-ratelimit-headers-11 (23 May 2026, read 2026-10-24): `"<policy>";r=<remaining>;
11
+ // t=<seconds>` and `"<policy>";q=<quota>;w=<window seconds>`, or `qu="concurrent-requests"` for a
12
+ // cap on requests at once. Retry-After is never earlier than the end of the effective window (§6).
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { createHmac, randomBytes } from 'node:crypto';
15
+ import { isIP } from 'node:net';
16
+ import { SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SlotlockAgentOperationError, slotlockAgentOperationScope, } from './agent-server.js';
17
+ import { slotlockBearerCredential } from './api-keys.js';
18
+ import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
19
+ import { SLOTLOCK_APPLICATION_REST_ROUTES, matchSlotlockRestRoute } from './rest-api.js';
20
+ /** Keys one policy remembers; past this, the least recently used tenth is dropped at once. */
21
+ const MAX_BUCKET_KEYS = 100_000;
22
+ /** Series writes that may wait for the server's slot, and how long each may wait. */
23
+ const MAX_SERIES_WAITING = 64;
24
+ const MAX_SERIES_WAIT_MS = 2_000;
25
+ /** A tenant's series writes that may wait behind its running one. */
26
+ const MAX_TENANT_SERIES_WAITING = 4;
27
+ /** The longest a freed slot rests, however long the event loop was busy while its write held it. */
28
+ const MAX_SLOT_REST_MS = 1_000;
29
+ /** A key's use of the slots counts half as much after this long. */
30
+ const SLOT_USE_HALF_LIFE_MS = 10_000;
31
+ /**
32
+ * Fields a proxy puts the client's address in: CF-Connecting-IP, True-Client-IP and X-Forwarded-For
33
+ * (developers.cloudflare.com/fundamentals/reference/http-headers, read 2026-10-24), X-Real-IP, and
34
+ * RFC 7239's Forwarded.
35
+ */
36
+ const PROXY_CLIENT_FIELDS = [
37
+ 'cf-connecting-ip',
38
+ 'true-client-ip',
39
+ 'x-forwarded-for',
40
+ 'x-real-ip',
41
+ 'forwarded',
42
+ ];
43
+ /** How often the refusal counts are logged when there are any. */
44
+ const REFUSAL_LOG_INTERVAL_MS = 60_000;
45
+ /** Clients named in one refusal log line, most refused first. */
46
+ const MAX_LOGGED_CLIENTS = 10;
47
+ /** Clients counted between two log lines; past this the rest are counted in the totals only. */
48
+ const MAX_COUNTED_CLIENTS = 1_000;
49
+ /** The IPv6 /64 of an address, which one client is generally assigned whole; null if not IPv6. */
50
+ function ipv6Prefix(address) {
51
+ let text = address.toLowerCase();
52
+ const tail = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
53
+ if (tail) {
54
+ const [a, b, c, d] = tail.slice(1).map(Number);
55
+ text = `${text.slice(0, tail.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
56
+ }
57
+ const [head = '', rest] = text.split('::');
58
+ const before = head === '' ? [] : head.split(':');
59
+ const after = rest === undefined || rest === '' ? [] : rest.split(':');
60
+ const groups = rest === undefined ? before : [...before, ...Array(8 - before.length - after.length).fill('0'), ...after];
61
+ if (groups.length !== 8)
62
+ return null;
63
+ const numbers = groups.map((group) => Number.parseInt(group, 16));
64
+ // An IPv4 client of a dual-stack listener: ::ffff:a.b.c.d.
65
+ if (numbers.slice(0, 5).every((group) => group === 0) && numbers[5] === 0xffff) {
66
+ const [high = 0, low = 0] = numbers.slice(6);
67
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
68
+ }
69
+ return `${numbers.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
70
+ }
71
+ /** The bucket key of an address: an IPv4 address itself, an IPv6 address's /64; null if neither. */
72
+ export function slotlockClientKey(value) {
73
+ const address = value.trim().replace(/^\[(.*)\]$/, '$1');
74
+ const kind = isIP(address);
75
+ if (kind === 4)
76
+ return address;
77
+ if (kind === 6)
78
+ return ipv6Prefix(address);
79
+ return null;
80
+ }
81
+ /**
82
+ * The network a client key (slotlockClientKey) signs in from: an IPv6 /64's /48, any other key
83
+ * itself. A site is generally given a /48 or a /56 (RFC 6177) and may use each /64 in it, so a
84
+ * budget per /64 would let one site sign in as thousands of clients and use up a ceiling every
85
+ * client shares; per /48, a site counts as one, as a site behind one IPv4 address does.
86
+ */
87
+ export function slotlockClientNetwork(key) {
88
+ const prefix = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):[0-9a-f]{1,4}::\/64$/.exec(key);
89
+ return prefix ? `${prefix[1]}::/48` : key;
90
+ }
91
+ /** The client a request is counted as: the trusted field's last address, else the connection's. */
92
+ function clientOf(request, connection, trustedField) {
93
+ if (trustedField !== undefined) {
94
+ // A proxy appends to a list field (X-Forwarded-For), so the last entry is the one it wrote.
95
+ const claimed = request.headers.get(trustedField)?.split(',').at(-1);
96
+ const key = claimed === undefined ? null : slotlockClientKey(claimed);
97
+ if (key !== null)
98
+ return key;
99
+ }
100
+ return slotlockClientKey(connection?.remoteAddress ?? '') ?? 'unknown';
101
+ }
102
+ function json(status, body, headers) {
103
+ return new Response(JSON.stringify(body), {
104
+ status,
105
+ headers: {
106
+ 'Cache-Control': 'private, no-store',
107
+ 'Content-Type': 'application/json; charset=utf-8',
108
+ 'X-Content-Type-Options': 'nosniff',
109
+ ...headers,
110
+ },
111
+ });
112
+ }
113
+ /**
114
+ * A refusal made before the request reaches the agent server, in the shape its protocol answers
115
+ * one: JSON-RPC on /mcp (-32029 to a 2025 client, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE to a
116
+ * 2026-07-28 one), a JSON-RPC error in a 200 envelope with a `google.rpc.ErrorInfo` on /a2a for a
117
+ * rate limit (A2A §9.5), as the agent server answers its own, and openapi.json's Error elsewhere.
118
+ */
119
+ function refusal(request, route, status, fields) {
120
+ const code = status === 429 ? 'rate_limited' : 'server_busy';
121
+ const message = status === 429 ? 'Rate limit exceeded' : 'Server is busy';
122
+ if (route === 'mcp') {
123
+ const modern = request.headers.get('mcp-protocol-version') === SLOTLOCK_MCP_PROTOCOL_VERSION;
124
+ const rpcCode = status === 503 ? -32603 : modern ? SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE : -32029;
125
+ return json(status, { jsonrpc: '2.0', id: null, error: { code: rpcCode, message } }, fields);
126
+ }
127
+ if (route === 'a2a') {
128
+ return json(status === 429 ? 200 : 503, {
129
+ jsonrpc: '2.0',
130
+ id: null,
131
+ error: {
132
+ code: status === 429 ? -32029 : -32603,
133
+ message,
134
+ data: [
135
+ {
136
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
137
+ reason: status === 429 ? 'RATE_LIMITED' : 'SERVER_BUSY',
138
+ domain: 'slotlock',
139
+ },
140
+ ],
141
+ },
142
+ }, fields);
143
+ }
144
+ if (route === 'sign_in') {
145
+ return new Response('Too many sign-ins from your network. Try again in a minute.', {
146
+ status,
147
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', ...fields },
148
+ });
149
+ }
150
+ return json(status, { error: { code } }, fields);
151
+ }
152
+ /** Retry-After and the RateLimit fields for a verdict. */
153
+ function fieldsOf(verdict, refused) {
154
+ const { name, rate } = verdict.policy;
155
+ const window = Math.max(1, Math.ceil(rate.burst / rate.perSecond));
156
+ const seconds = Math.max(refused ? 1 : 0, Math.ceil(verdict.seconds));
157
+ return {
158
+ ...(refused ? { 'Retry-After': String(seconds) } : {}),
159
+ RateLimit: `"${name}";r=${Math.max(0, Math.floor(verdict.remaining))};t=${seconds}`,
160
+ 'RateLimit-Policy': `"${name}";q=${rate.burst};w=${window}`,
161
+ };
162
+ }
163
+ /** Fields for a refusal by a cap on requests at once. */
164
+ function concurrencyFields(name, cap) {
165
+ return {
166
+ 'Retry-After': '1',
167
+ RateLimit: `"${name}";r=0`,
168
+ 'RateLimit-Policy': `"${name}";q=${cap};qu="concurrent-requests"`,
169
+ };
170
+ }
171
+ /** Token buckets of one policy, one per key, refilled lazily. */
172
+ function bucketsFor(rate, now) {
173
+ const buckets = new Map();
174
+ const refill = (bucket, at) => {
175
+ bucket.tokens = Math.min(rate.burst, bucket.tokens + ((at - bucket.at) / 1_000) * rate.perSecond);
176
+ bucket.at = at;
177
+ };
178
+ return {
179
+ /** The key's bucket, refilled to `at` and moved to the most recently used end. */
180
+ get(key, at) {
181
+ let bucket = buckets.get(key);
182
+ if (bucket) {
183
+ buckets.delete(key);
184
+ refill(bucket, at);
185
+ }
186
+ else {
187
+ bucket = { tokens: rate.burst, at };
188
+ if (buckets.size >= MAX_BUCKET_KEYS) {
189
+ // A tenth at a time, so a flood from ever new addresses costs one sweep per 10,000 of
190
+ // them rather than one per request. A dropped key starts afresh with a full bucket.
191
+ let dropping = MAX_BUCKET_KEYS / 10;
192
+ for (const stale of buckets.keys()) {
193
+ if (dropping === 0)
194
+ break;
195
+ buckets.delete(stale);
196
+ dropping -= 1;
197
+ }
198
+ }
199
+ }
200
+ buckets.set(key, bucket);
201
+ return bucket;
202
+ },
203
+ };
204
+ }
205
+ /**
206
+ * Slots, at most `perKey` running for a key and `total` in all, shared between keys by how much
207
+ * each asks for. A key's use is how long the event loop was busy while its writes held a slot,
208
+ * halving every SLOT_USE_HALF_LIFE_MS. A freed slot goes to the waiter whose key has the fewest
209
+ * writes in flight (running and waiting), then to the one whose key has used the slots least, then
210
+ * to the first come. So a key sending one write at a time goes ahead of keys keeping several in
211
+ * flight, and of keys with one each whose writes kept the loop busier; keys with one each and less
212
+ * use, a new key included, each go ahead of it once, so its wait grows with how many there are.
213
+ * When the queue is full, a key with fewer writes in flight than the key with the most takes the
214
+ * place of that key's last waiter, so keys that each stay within their own cap cannot together
215
+ * turn every other key away. A freed slot first rests for as long as the event loop was busy
216
+ * while its write held it (up to MAX_SLOT_REST_MS): a request that needs a few turns of the loop
217
+ * before it is answered (another tenant's read, or its write on its way to a slot) has them in
218
+ * the rest, so writes that each hold the loop for under MAX_SLOT_REST_MS hold at most about half
219
+ * of it.
220
+ */
221
+ function seriesGate(perKey, total) {
222
+ let running = 0;
223
+ const byKey = new Map();
224
+ const waiting = [];
225
+ const add = (key, delta) => {
226
+ const count = (byKey.get(key) ?? 0) + delta;
227
+ if (count === 0)
228
+ byKey.delete(key);
229
+ else
230
+ byKey.set(key, count);
231
+ };
232
+ const runningFor = new Map();
233
+ /** Each key's use as of `at`, in milliseconds; least recently changed first. */
234
+ const used = new Map();
235
+ const useOf = (key, at) => {
236
+ const entry = used.get(key);
237
+ return entry ? entry.ms * 0.5 ** ((at - entry.at) / SLOT_USE_HALF_LIFE_MS) : 0;
238
+ };
239
+ /** Whether a hand-over is due on a later turn: one at a time, so slots never go out in a run. */
240
+ let handing = false;
241
+ const release = (key, busyMs) => {
242
+ const at = performance.now();
243
+ const ms = useOf(key, at) + busyMs;
244
+ used.delete(key);
245
+ used.set(key, { ms, at });
246
+ // Forgetting the key whose use changed longest ago forgets the use that has had longest to
247
+ // fade.
248
+ if (used.size > MAX_BUCKET_KEYS)
249
+ used.delete(used.keys().next().value);
250
+ const count = (runningFor.get(key) ?? 1) - 1;
251
+ if (count === 0)
252
+ runningFor.delete(key);
253
+ else
254
+ runningFor.set(key, count);
255
+ add(key, -1);
256
+ const rest = Math.min(MAX_SLOT_REST_MS, Math.floor(busyMs));
257
+ if (rest > 0) {
258
+ const timer = setTimeout(() => {
259
+ running -= 1;
260
+ next();
261
+ }, rest);
262
+ timer.unref?.();
263
+ return;
264
+ }
265
+ running -= 1;
266
+ // Even after a write that hardly held the loop, the slot goes over on a later turn of it, so
267
+ // the requests waiting on I/O are served first.
268
+ if (handing)
269
+ return;
270
+ handing = true;
271
+ setImmediate(() => {
272
+ handing = false;
273
+ next();
274
+ });
275
+ };
276
+ const next = () => {
277
+ const at = performance.now();
278
+ while (running < total) {
279
+ let index = -1;
280
+ let best;
281
+ for (const [position, { key }] of waiting.entries()) {
282
+ if ((runningFor.get(key) ?? 0) >= perKey)
283
+ continue;
284
+ const rank = { inFlight: byKey.get(key) ?? 0, use: useOf(key, at) };
285
+ if (!best ||
286
+ rank.inFlight < best.inFlight ||
287
+ (rank.inFlight === best.inFlight && rank.use < best.use)) {
288
+ best = rank;
289
+ index = position;
290
+ }
291
+ }
292
+ if (index === -1)
293
+ return;
294
+ const [chosen] = waiting.splice(index, 1);
295
+ chosen?.start();
296
+ }
297
+ };
298
+ return {
299
+ /**
300
+ * A release function once a slot is the caller's; `tenant` when the key already has as many
301
+ * running and waiting as it may, `server` when the server's queue is full or the wait ran out.
302
+ */
303
+ acquire(key, signal) {
304
+ if ((byKey.get(key) ?? 0) >= perKey + MAX_TENANT_SERIES_WAITING) {
305
+ return Promise.resolve('tenant');
306
+ }
307
+ const start = () => {
308
+ running += 1;
309
+ runningFor.set(key, (runningFor.get(key) ?? 0) + 1);
310
+ // Node's performance.eventLoopUtilization(earlier).active is the time since `earlier` the
311
+ // loop spent outside its event provider, where it waits for I/O (nodejs.org/api/
312
+ // perf_hooks.html, read 2026-10-25): a synchronous expansion counts in full, the wait for
313
+ // a query's answer only for the other work the loop runs meanwhile.
314
+ const loop = performance.eventLoopUtilization();
315
+ let released = false;
316
+ return () => {
317
+ if (released)
318
+ return;
319
+ released = true;
320
+ release(key, performance.eventLoopUtilization(loop).active);
321
+ };
322
+ };
323
+ // Straight in when there is room, unless a waiter could use it: that one goes first.
324
+ const room = (candidate) => (runningFor.get(candidate) ?? 0) < perKey;
325
+ if (running < total && room(key) && !waiting.some((entry) => room(entry.key))) {
326
+ add(key, 1);
327
+ return Promise.resolve(start());
328
+ }
329
+ if (signal.aborted)
330
+ return Promise.resolve('server');
331
+ if (waiting.length >= MAX_SERIES_WAITING) {
332
+ // A full queue makes room for a key with fewer writes in flight than another waiter's: the
333
+ // last to come of the key with the most gives up its place. Otherwise many keys, each
334
+ // within its own cap, could fill the queue and turn every other key away.
335
+ const mine = (byKey.get(key) ?? 0) + 1;
336
+ let most = mine;
337
+ let displaced;
338
+ for (let index = waiting.length - 1; index >= 0; index -= 1) {
339
+ const entry = waiting[index];
340
+ const theirs = byKey.get(entry.key) ?? 0;
341
+ if (theirs > most) {
342
+ most = theirs;
343
+ displaced = entry;
344
+ }
345
+ }
346
+ if (!displaced)
347
+ return Promise.resolve('server');
348
+ displaced.give();
349
+ }
350
+ add(key, 1);
351
+ return new Promise((resolve) => {
352
+ const entry = {
353
+ key,
354
+ give: () => give(),
355
+ start: () => {
356
+ clearTimeout(timer);
357
+ signal.removeEventListener('abort', give);
358
+ resolve(start());
359
+ },
360
+ };
361
+ const give = () => {
362
+ const index = waiting.indexOf(entry);
363
+ if (index === -1)
364
+ return;
365
+ waiting.splice(index, 1);
366
+ clearTimeout(timer);
367
+ signal.removeEventListener('abort', give);
368
+ add(key, -1);
369
+ resolve((runningFor.get(key) ?? 0) >= perKey ? 'tenant' : 'server');
370
+ };
371
+ const timer = setTimeout(give, MAX_SERIES_WAIT_MS);
372
+ timer.unref?.();
373
+ signal.addEventListener('abort', give, { once: true });
374
+ waiting.push(entry);
375
+ });
376
+ },
377
+ };
378
+ }
379
+ /**
380
+ * The slots an operation waits for: `series` for a write that may expand a recurring series (a
381
+ * recurring create, an update), `maintenance` for a maintenance run, which rolls many series and so
382
+ * has slots of its own rather than holding every other tenant's writes; null for the rest.
383
+ */
384
+ function slotsFor(operation, input) {
385
+ if (operation === 'slotlock_run_maintenance')
386
+ return 'maintenance';
387
+ if (operation === 'slotlock_create_event') {
388
+ return input.recurrence_rule !== undefined && input.recurrence_rule !== null ? 'series' : null;
389
+ }
390
+ return operation === 'slotlock_update_event' ? 'series' : null;
391
+ }
392
+ /** The limits `slotlock serve` puts in front of its agent server and dashboard. */
393
+ export function createSlotlockRequestLimits(options) {
394
+ const now = options.now ?? (() => performance.now());
395
+ const { rates, caps } = options.limits;
396
+ const policies = new Map();
397
+ for (const [name, rate] of Object.entries(rates)) {
398
+ if (rate !== null)
399
+ policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate, now) });
400
+ }
401
+ const states = new AsyncLocalStorage();
402
+ const looked = new WeakMap();
403
+ const addresses = new WeakMap();
404
+ const gates = {
405
+ series: {
406
+ gate: seriesGate(caps.tenant_series_writes, caps.series_writes),
407
+ tenant: { name: 'tenant_series_writes', cap: caps.tenant_series_writes },
408
+ server: { name: 'series_writes', cap: caps.series_writes },
409
+ busy: 'series_writes_busy',
410
+ },
411
+ // One a tenant: a second would roll the same series again.
412
+ maintenance: {
413
+ gate: seriesGate(1, caps.maintenance_runs),
414
+ tenant: { name: 'tenant_maintenance_runs', cap: 1 },
415
+ server: { name: 'maintenance_runs', cap: caps.maintenance_runs },
416
+ busy: 'maintenance_busy',
417
+ },
418
+ };
419
+ const signInPaths = new Set(options.signInPaths ?? []);
420
+ let fieldIgnored = false;
421
+ // Refusal counts, logged with each client keyed by a secret this process alone holds.
422
+ const pseudonymKey = randomBytes(32);
423
+ const pseudonym = (address) => createHmac('sha256', pseudonymKey).update(address).digest('hex').slice(0, 16);
424
+ let counted = emptyCounts();
425
+ let countedSince = now();
426
+ let clients = new Map();
427
+ function emptyCounts() {
428
+ return {
429
+ auth_refused: 0,
430
+ rate_limited: 0,
431
+ lookup_overflow: 0,
432
+ series_writes_busy: 0,
433
+ maintenance_busy: 0,
434
+ request_too_large: 0,
435
+ };
436
+ }
437
+ const count = (reason, address) => {
438
+ counted[reason] += 1;
439
+ const client = pseudonym(address);
440
+ if (clients.has(client) || clients.size < MAX_COUNTED_CLIENTS) {
441
+ clients.set(client, (clients.get(client) ?? 0) + 1);
442
+ }
443
+ };
444
+ const flush = () => {
445
+ const total = Object.values(counted).reduce((sum, value) => sum + value, 0);
446
+ const at = now();
447
+ if (total > 0) {
448
+ const line = {
449
+ window_seconds: Math.round((at - countedSince) / 1_000),
450
+ ...counted,
451
+ clients: [...clients]
452
+ .sort((a, b) => b[1] - a[1])
453
+ .slice(0, MAX_LOGGED_CLIENTS)
454
+ .map(([client, refusals]) => ({ client, refusals })),
455
+ };
456
+ try {
457
+ options.onRefusals?.(line);
458
+ }
459
+ catch {
460
+ // Observability must never change an answer.
461
+ }
462
+ }
463
+ counted = emptyCounts();
464
+ clients = new Map();
465
+ countedSince = at;
466
+ };
467
+ const timer = setInterval(flush, REFUSAL_LOG_INTERVAL_MS);
468
+ timer.unref?.();
469
+ /**
470
+ * Take a token from every bucket named, or from none: one with less than a token refuses the
471
+ * request, and the one that frees soonest is not the answer, the one that frees last is.
472
+ */
473
+ function charge(checks) {
474
+ const at = now();
475
+ const found = checks.flatMap(({ name, key }) => {
476
+ const entry = policies.get(name);
477
+ return entry ? [{ policy: entry.policy, bucket: entry.buckets.get(key, at) }] : [];
478
+ });
479
+ if (found.length === 0)
480
+ return { ok: true };
481
+ const short = found.filter(({ bucket }) => bucket.tokens < 1);
482
+ if (short.length > 0) {
483
+ const waits = short.map(({ policy, bucket }) => ({
484
+ policy,
485
+ remaining: 0,
486
+ seconds: (1 - bucket.tokens) / policy.rate.perSecond,
487
+ }));
488
+ waits.sort((a, b) => b.seconds - a.seconds);
489
+ return { ok: false, verdict: waits[0] };
490
+ }
491
+ let tightest;
492
+ for (const { policy, bucket } of found) {
493
+ bucket.tokens -= 1;
494
+ const verdict = {
495
+ policy,
496
+ remaining: bucket.tokens,
497
+ seconds: (policy.rate.burst - bucket.tokens) / policy.rate.perSecond,
498
+ };
499
+ if (!tightest || verdict.remaining < tightest.remaining)
500
+ tightest = verdict;
501
+ }
502
+ return tightest ? { ok: true, verdict: tightest } : { ok: true };
503
+ }
504
+ /**
505
+ * Take the read or write tokens a request has not yet been charged: from its key and tenant, or
506
+ * its tenant's application buckets when it may use the application API (an embedder's principal
507
+ * with no scopes, or one with the admin scope), and from the server's. A key that may not is
508
+ * charged its own buckets, so its refused application calls cannot spend the application's.
509
+ * False, with the refusal recorded, when one is short.
510
+ */
511
+ function chargeRequest(state, principal, classes) {
512
+ const due = classes.filter((kind) => !state.charged[kind]);
513
+ if (due.length === 0)
514
+ return true;
515
+ const keyOf = `${principal.tenantRef}\0${principal.subject}`;
516
+ const application = state.application &&
517
+ (principal.scopes === undefined || principal.scopes.includes(SLOTLOCK_APPLICATION_SCOPE));
518
+ const checks = due.flatMap((kind) => [
519
+ ...(application
520
+ ? [{ name: `app_${kind}`, key: principal.tenantRef }]
521
+ : [
522
+ { name: `key_${kind}`, key: keyOf },
523
+ { name: `tenant_${kind}`, key: principal.tenantRef },
524
+ ]),
525
+ { name: `global_${kind}`, key: '' },
526
+ ]);
527
+ const outcome = charge(checks);
528
+ if (!outcome.ok) {
529
+ state.refusal = {
530
+ reason: 'rate_limited',
531
+ fields: fieldsOf(outcome.verdict, true),
532
+ };
533
+ return false;
534
+ }
535
+ for (const kind of due)
536
+ state.charged[kind] = true;
537
+ if (outcome.verdict)
538
+ state.tightest = outcome.verdict;
539
+ return true;
540
+ }
541
+ function routeOf(request) {
542
+ const pathname = new URL(request.url).pathname;
543
+ const base = options.basePath;
544
+ if (base !== '' && pathname !== base && !pathname.startsWith(`${base}/`))
545
+ return 'other';
546
+ const path = pathname.slice(base.length) || '/';
547
+ if (path === '/mcp')
548
+ return 'mcp';
549
+ if (path === '/a2a')
550
+ return 'a2a';
551
+ if (path === '/healthz' && request.method === 'GET')
552
+ return 'health';
553
+ if (path.startsWith('/v1/') || path === '/openapi.json')
554
+ return 'rest';
555
+ // The dashboard routes one trailing slash as the path without it.
556
+ if (signInPaths.has(path.replace(/(.)\/$/, '$1')))
557
+ return 'sign_in';
558
+ return 'other';
559
+ }
560
+ /** Whether a REST request is to a route of the application API rather than an agent tool. */
561
+ function isApplicationRoute(request) {
562
+ const path = new URL(request.url).pathname.slice(options.basePath.length);
563
+ const match = matchSlotlockRestRoute(request.method, path, SLOTLOCK_APPLICATION_REST_ROUTES);
564
+ return match !== null && 'route' in match;
565
+ }
566
+ return {
567
+ async authenticate(request) {
568
+ if (looked.has(request))
569
+ return looked.get(request) ?? null;
570
+ return options.authenticate(request);
571
+ },
572
+ async consumeRateLimit({ principal, operation }) {
573
+ const state = states.getStore();
574
+ if (!state)
575
+ return true;
576
+ const classes = [];
577
+ if (operation === 'protocol') {
578
+ classes.push('read');
579
+ if (state.write)
580
+ classes.push('write');
581
+ }
582
+ else if (slotlockAgentOperationScope(operation) === 'write') {
583
+ classes.push('write');
584
+ }
585
+ return chargeRequest(state, principal, classes);
586
+ },
587
+ async admit({ principal, operation, input }) {
588
+ const state = states.getStore();
589
+ const kind = state ? slotsFor(operation, input) : null;
590
+ if (!state || kind === null)
591
+ return;
592
+ // One slot a request: the confirmation round or a retry within it holds the same one.
593
+ if (state.release.length > 0)
594
+ return;
595
+ // Each operation that waits here writes. Its token is taken now rather than after authorize,
596
+ // where consumeRateLimit would take it (on /mcp and /a2a a request is charged a read token
597
+ // only): a write over its budget is refused at once, not after holding a place in the queue
598
+ // for a slot it could not use.
599
+ if (!chargeRequest(state, principal, ['write'])) {
600
+ throw new SlotlockAgentOperationError('rate_limited', 429);
601
+ }
602
+ const slots = gates[kind];
603
+ const slot = await slots.gate.acquire(principal.tenantRef, state.signal);
604
+ if (typeof slot === 'function') {
605
+ state.release.push(slot);
606
+ return;
607
+ }
608
+ if (slot === 'tenant') {
609
+ state.refusal = {
610
+ reason: 'rate_limited',
611
+ fields: concurrencyFields(slots.tenant.name, slots.tenant.cap),
612
+ };
613
+ throw new SlotlockAgentOperationError('rate_limited', 429);
614
+ }
615
+ state.refusal = {
616
+ reason: slots.busy,
617
+ fields: concurrencyFields(slots.server.name, slots.server.cap),
618
+ };
619
+ throw new SlotlockAgentOperationError('server_busy', 503);
620
+ },
621
+ serve(target) {
622
+ const limited = {
623
+ async fetch(request, connection) {
624
+ const route = routeOf(request);
625
+ const address = clientOf(request, connection, options.trustedProxyHeader);
626
+ addresses.set(request, address);
627
+ if (options.trustedProxyHeader === undefined && !fieldIgnored) {
628
+ const field = PROXY_CLIENT_FIELDS.find((name) => request.headers.has(name));
629
+ if (field !== undefined) {
630
+ fieldIgnored = true;
631
+ try {
632
+ options.onIgnoredClientField?.(field);
633
+ }
634
+ catch {
635
+ // Observability must never change an answer.
636
+ }
637
+ }
638
+ }
639
+ // Before anything is looked up: every request from the address, and its sign-ins from
640
+ // its network, as the dashboard Worker counts them. Not the health check, which a proxy
641
+ // in front asks from the address its requests share, and which reuses one database
642
+ // check a second.
643
+ const before = charge([
644
+ ...(route === 'health' ? [] : [{ name: 'address', key: address }]),
645
+ ...(route === 'sign_in'
646
+ ? [{ name: 'sign_in', key: slotlockClientNetwork(address) }]
647
+ : []),
648
+ ]);
649
+ if (!before.ok) {
650
+ count('rate_limited', address);
651
+ return refusal(request, route, 429, fieldsOf(before.verdict, true));
652
+ }
653
+ const state = {
654
+ route,
655
+ address,
656
+ write: route === 'rest' && request.method !== 'GET' && request.method !== 'HEAD',
657
+ application: route === 'rest' && isApplicationRoute(request),
658
+ signal: request.signal,
659
+ charged: { read: false, write: false },
660
+ release: [],
661
+ };
662
+ if ((route === 'mcp' || route === 'a2a' || route === 'rest') &&
663
+ slotlockBearerCredential(request) !== undefined) {
664
+ // One lookup a request, made here so a full queue answers busy instead of anonymous.
665
+ try {
666
+ looked.set(request, await options.authenticate(request));
667
+ }
668
+ catch (error) {
669
+ if (!(error instanceof SlotlockAgentOperationError) || error.status !== 503)
670
+ throw error;
671
+ count('lookup_overflow', address);
672
+ return refusal(request, route, 503, { 'Retry-After': '1' });
673
+ }
674
+ }
675
+ let response;
676
+ try {
677
+ response = await states.run(state, () => target.fetch(request, connection));
678
+ }
679
+ finally {
680
+ for (const release of state.release.splice(0))
681
+ release();
682
+ }
683
+ if (response.status === 401)
684
+ count('auth_refused', address);
685
+ if (response.status === 413)
686
+ count('request_too_large', address);
687
+ if (state.refusal)
688
+ count(state.refusal.reason, address);
689
+ const fields = state.refusal?.fields ??
690
+ (state.tightest && (route === 'mcp' || route === 'a2a' || route === 'rest')
691
+ ? fieldsOf(state.tightest, false)
692
+ : undefined);
693
+ if (!fields)
694
+ return response;
695
+ // A response's own headers may be immutable (Response.redirect): answer with a copy.
696
+ const headers = new Headers(response.headers);
697
+ for (const [name, value] of Object.entries(fields))
698
+ headers.set(name, value);
699
+ return new Response(response.body, {
700
+ status: response.status,
701
+ statusText: response.statusText,
702
+ headers,
703
+ });
704
+ },
705
+ };
706
+ if (target.shutdown)
707
+ limited.shutdown = () => target.shutdown?.();
708
+ return limited;
709
+ },
710
+ clientAddress(request) {
711
+ return addresses.get(request);
712
+ },
713
+ close() {
714
+ clearInterval(timer);
715
+ flush();
716
+ },
717
+ };
718
+ }