slotlock 0.6.2 → 0.7.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 +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/rate-limit.js
CHANGED
|
@@ -10,12 +10,17 @@
|
|
|
10
10
|
// draft-ietf-httpapi-ratelimit-headers-11 (23 May 2026, read 2026-10-24): `"<policy>";r=<remaining>;
|
|
11
11
|
// t=<seconds>` and `"<policy>";q=<quota>;w=<window seconds>`, or `qu="concurrent-requests"` for a
|
|
12
12
|
// cap on requests at once. Retry-After is never earlier than the end of the effective window (§6).
|
|
13
|
+
// With monthly quotas (quotas.ts), each operation's answer also lists them, `"operations_month"` and
|
|
14
|
+
// `"bookings_month"`, counting down to the stop, and one over its quota is refused `quota_exceeded`
|
|
15
|
+
// with Retry-After at the month's end.
|
|
13
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
-
import { createHmac, randomBytes } from 'node:crypto';
|
|
17
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
15
18
|
import { isIP } from 'node:net';
|
|
16
19
|
import { SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SlotlockAgentOperationError, slotlockAgentOperationScope, } from './agent-server.js';
|
|
17
20
|
import { slotlockBearerCredential } from './api-keys.js';
|
|
18
21
|
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
22
|
+
import { SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, } from './config.js';
|
|
23
|
+
import { slotlockQuotaFields, slotlockQuotaRefusal } from './quotas.js';
|
|
19
24
|
import { SLOTLOCK_APPLICATION_REST_ROUTES, matchSlotlockRestRoute } from './rest-api.js';
|
|
20
25
|
/** Keys one policy remembers; past this, the least recently used tenth is dropped at once. */
|
|
21
26
|
const MAX_BUCKET_KEYS = 100_000;
|
|
@@ -46,6 +51,8 @@ const REFUSAL_LOG_INTERVAL_MS = 60_000;
|
|
|
46
51
|
const MAX_LOGGED_CLIENTS = 10;
|
|
47
52
|
/** Clients counted between two log lines; past this the rest are counted in the totals only. */
|
|
48
53
|
const MAX_COUNTED_CLIENTS = 1_000;
|
|
54
|
+
/** Distinct per-key rates (RFC 0004 A5) one server keeps buckets for; past this the oldest goes. */
|
|
55
|
+
const MAX_KEY_LIMIT_RATES = 1_000;
|
|
49
56
|
/** The IPv6 /64 of an address, which one client is generally assigned whole; null if not IPv6. */
|
|
50
57
|
function ipv6Prefix(address) {
|
|
51
58
|
let text = address.toLowerCase();
|
|
@@ -88,9 +95,23 @@ export function slotlockClientNetwork(key) {
|
|
|
88
95
|
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
96
|
return prefix ? `${prefix[1]}::/48` : key;
|
|
90
97
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
const sha256 = (value) => createHash('sha256').update(value, 'utf8').digest();
|
|
99
|
+
/**
|
|
100
|
+
* Whether the request carries the proxy's secret. Compared as SHA-256 digests in constant time, so
|
|
101
|
+
* neither the time taken nor a length tells how much of a guess matched. A repeated field arrives
|
|
102
|
+
* joined with a comma and so never matches.
|
|
103
|
+
*/
|
|
104
|
+
function provenByProxy(request, secretDigest) {
|
|
105
|
+
const presented = request.headers.get(SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD);
|
|
106
|
+
return presented !== null && timingSafeEqual(sha256(presented), secretDigest);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The client a request is counted as: the trusted field's last address, else the connection's.
|
|
110
|
+
* With a proxy secret, the field is believed only beside the proof.
|
|
111
|
+
*/
|
|
112
|
+
function clientOf(request, connection, trustedField, secretDigest) {
|
|
113
|
+
const believed = secretDigest === undefined || provenByProxy(request, secretDigest);
|
|
114
|
+
if (trustedField !== undefined && believed) {
|
|
94
115
|
// A proxy appends to a list field (X-Forwarded-For), so the last entry is the one it wrote.
|
|
95
116
|
const claimed = request.headers.get(trustedField)?.split(',').at(-1);
|
|
96
117
|
const key = claimed === undefined ? null : slotlockClientKey(claimed);
|
|
@@ -169,7 +190,7 @@ function concurrencyFields(name, cap) {
|
|
|
169
190
|
};
|
|
170
191
|
}
|
|
171
192
|
/** Token buckets of one policy, one per key, refilled lazily. */
|
|
172
|
-
function bucketsFor(rate
|
|
193
|
+
function bucketsFor(rate) {
|
|
173
194
|
const buckets = new Map();
|
|
174
195
|
const refill = (bucket, at) => {
|
|
175
196
|
bucket.tokens = Math.min(rate.burst, bucket.tokens + ((at - bucket.at) / 1_000) * rate.perSecond);
|
|
@@ -211,7 +232,7 @@ export function createSlotlockRateLimiter(rate, now = () => performance.now()) {
|
|
|
211
232
|
if (!(rate.perSecond > 0) || !(rate.burst >= 1)) {
|
|
212
233
|
throw new Error('Slotlock rate limiter needs a positive rate and a burst of at least 1');
|
|
213
234
|
}
|
|
214
|
-
const buckets = bucketsFor(rate
|
|
235
|
+
const buckets = bucketsFor(rate);
|
|
215
236
|
return {
|
|
216
237
|
take(key) {
|
|
217
238
|
const bucket = buckets.get(key, now());
|
|
@@ -412,15 +433,31 @@ function slotsFor(operation, input) {
|
|
|
412
433
|
/** The limits `slotlock serve` puts in front of its agent server and dashboard. */
|
|
413
434
|
export function createSlotlockRequestLimits(options) {
|
|
414
435
|
const now = options.now ?? (() => performance.now());
|
|
436
|
+
const clock = options.clock ?? Date.now;
|
|
415
437
|
const { rates, caps } = options.limits;
|
|
416
438
|
const policies = new Map();
|
|
417
439
|
for (const [name, rate] of Object.entries(rates)) {
|
|
418
440
|
if (rate !== null)
|
|
419
|
-
policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate
|
|
441
|
+
policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate) });
|
|
420
442
|
}
|
|
443
|
+
// Keys' own rates (RFC 0004 A5), one set of buckets for each rate in use; a key's bucket is keyed
|
|
444
|
+
// by key and rate, so a changed rate starts a fresh bucket.
|
|
445
|
+
const keyLimits = new Map();
|
|
446
|
+
const keyLimitEntry = (perMinute) => {
|
|
447
|
+
let entry = keyLimits.get(perMinute);
|
|
448
|
+
if (!entry) {
|
|
449
|
+
if (keyLimits.size >= MAX_KEY_LIMIT_RATES)
|
|
450
|
+
keyLimits.delete(keyLimits.keys().next().value);
|
|
451
|
+
const rate = { perSecond: perMinute / 60, burst: perMinute };
|
|
452
|
+
entry = { policy: { name: 'key_limit', rate }, buckets: bucketsFor(rate) };
|
|
453
|
+
keyLimits.set(perMinute, entry);
|
|
454
|
+
}
|
|
455
|
+
return entry;
|
|
456
|
+
};
|
|
421
457
|
const states = new AsyncLocalStorage();
|
|
422
458
|
const looked = new WeakMap();
|
|
423
459
|
const addresses = new WeakMap();
|
|
460
|
+
const proxySecretDigest = options.trustedProxySecret === undefined ? undefined : sha256(options.trustedProxySecret);
|
|
424
461
|
const gates = {
|
|
425
462
|
series: {
|
|
426
463
|
gate: seriesGate(caps.tenant_series_writes, caps.series_writes),
|
|
@@ -452,6 +489,7 @@ export function createSlotlockRequestLimits(options) {
|
|
|
452
489
|
series_writes_busy: 0,
|
|
453
490
|
maintenance_busy: 0,
|
|
454
491
|
request_too_large: 0,
|
|
492
|
+
quota_exceeded: 0,
|
|
455
493
|
};
|
|
456
494
|
}
|
|
457
495
|
const count = (reason, address) => {
|
|
@@ -492,8 +530,8 @@ export function createSlotlockRequestLimits(options) {
|
|
|
492
530
|
*/
|
|
493
531
|
function charge(checks) {
|
|
494
532
|
const at = now();
|
|
495
|
-
const found = checks.flatMap(({ name, key }) => {
|
|
496
|
-
const entry = policies.get(name);
|
|
533
|
+
const found = checks.flatMap(({ name, key, entry: given }) => {
|
|
534
|
+
const entry = given ?? policies.get(name);
|
|
497
535
|
return entry ? [{ policy: entry.policy, bucket: entry.buckets.get(key, at) }] : [];
|
|
498
536
|
});
|
|
499
537
|
if (found.length === 0)
|
|
@@ -535,15 +573,21 @@ export function createSlotlockRequestLimits(options) {
|
|
|
535
573
|
const keyOf = `${principal.tenantRef}\0${principal.subject}`;
|
|
536
574
|
const application = state.application &&
|
|
537
575
|
(principal.scopes === undefined || principal.scopes.includes(SLOTLOCK_APPLICATION_SCOPE));
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
576
|
+
// A key's own rate (RFC 0004 A5): a minute's requests, reads and writes alike, as a bucket of
|
|
577
|
+
// its own beside the server's, so it only ever lowers what the key may do.
|
|
578
|
+
const own = principal.limits?.requestsPerMinute;
|
|
579
|
+
const keyLimit = own !== undefined && Number.isSafeInteger(own) && own >= 1 && !state.chargedKeyLimit
|
|
580
|
+
? [{ name: 'key_limit', key: `${keyOf}\0${own}`, entry: keyLimitEntry(own) }]
|
|
581
|
+
: [];
|
|
582
|
+
const checks = [...keyLimit, ...due.flatMap((kind) => [
|
|
583
|
+
...(application
|
|
584
|
+
? [{ name: `app_${kind}`, key: principal.tenantRef }]
|
|
585
|
+
: [
|
|
586
|
+
{ name: `key_${kind}`, key: keyOf },
|
|
587
|
+
{ name: `tenant_${kind}`, key: principal.tenantRef },
|
|
588
|
+
]),
|
|
589
|
+
{ name: `global_${kind}`, key: '' },
|
|
590
|
+
])];
|
|
547
591
|
const outcome = charge(checks);
|
|
548
592
|
if (!outcome.ok) {
|
|
549
593
|
state.refusal = {
|
|
@@ -577,6 +621,8 @@ export function createSlotlockRequestLimits(options) {
|
|
|
577
621
|
}
|
|
578
622
|
for (const kind of due)
|
|
579
623
|
state.charged[kind] = true;
|
|
624
|
+
if (keyLimit.length > 0)
|
|
625
|
+
state.chargedKeyLimit = true;
|
|
580
626
|
if (outcome.verdict)
|
|
581
627
|
state.tightest = outcome.verdict;
|
|
582
628
|
return true;
|
|
@@ -612,17 +658,18 @@ export function createSlotlockRequestLimits(options) {
|
|
|
612
658
|
return looked.get(request) ?? null;
|
|
613
659
|
return options.authenticate(request);
|
|
614
660
|
},
|
|
615
|
-
async consumeRateLimit({ principal, operation }) {
|
|
661
|
+
async consumeRateLimit({ principal, operation, dryRun }) {
|
|
616
662
|
const state = states.getStore();
|
|
617
663
|
if (!state)
|
|
618
664
|
return true;
|
|
619
665
|
const classes = [];
|
|
620
666
|
if (operation === 'protocol') {
|
|
621
667
|
classes.push('read');
|
|
622
|
-
if (state.write)
|
|
668
|
+
if (state.write && dryRun !== true)
|
|
623
669
|
classes.push('write');
|
|
624
670
|
}
|
|
625
|
-
else if (slotlockAgentOperationScope(operation) === 'write') {
|
|
671
|
+
else if (slotlockAgentOperationScope(operation) === 'write' && dryRun !== true) {
|
|
672
|
+
// A dry run writes nothing: the request's read token is its charge (RFC 0004 A3).
|
|
626
673
|
classes.push('write');
|
|
627
674
|
}
|
|
628
675
|
return chargeRequest(state, principal, classes);
|
|
@@ -661,11 +708,38 @@ export function createSlotlockRequestLimits(options) {
|
|
|
661
708
|
};
|
|
662
709
|
throw new SlotlockAgentOperationError('server_busy', 503);
|
|
663
710
|
},
|
|
711
|
+
async quota({ principal, operation }) {
|
|
712
|
+
if (!options.quotas)
|
|
713
|
+
return;
|
|
714
|
+
const state = states.getStore();
|
|
715
|
+
const verdict = await options.quotas.check({ tenantRef: principal.tenantRef, operation });
|
|
716
|
+
const at = clock();
|
|
717
|
+
if (verdict.ok) {
|
|
718
|
+
if (state && verdict.standings.length > 0) {
|
|
719
|
+
const { rateLimit, policy } = slotlockQuotaFields(verdict.standings, at);
|
|
720
|
+
state.quota = { rateLimit, policy };
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const { exceeded } = verdict;
|
|
725
|
+
const { rateLimit, policy, retryAfter } = slotlockQuotaFields([exceeded], at);
|
|
726
|
+
if (state) {
|
|
727
|
+
state.refusal = {
|
|
728
|
+
reason: 'quota_exceeded',
|
|
729
|
+
fields: {
|
|
730
|
+
'Retry-After': String(retryAfter),
|
|
731
|
+
RateLimit: rateLimit.join(', '),
|
|
732
|
+
'RateLimit-Policy': policy.join(', '),
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
throw slotlockQuotaRefusal(exceeded);
|
|
737
|
+
},
|
|
664
738
|
serve(target) {
|
|
665
739
|
const limited = {
|
|
666
740
|
async fetch(request, connection) {
|
|
667
741
|
const route = routeOf(request);
|
|
668
|
-
const address = clientOf(request, connection, options.trustedProxyHeader);
|
|
742
|
+
const address = clientOf(request, connection, options.trustedProxyHeader, proxySecretDigest);
|
|
669
743
|
addresses.set(request, address);
|
|
670
744
|
if (options.trustedProxyHeader === undefined && !fieldIgnored) {
|
|
671
745
|
const field = PROXY_CLIENT_FIELDS.find((name) => request.headers.has(name));
|
|
@@ -729,10 +803,19 @@ export function createSlotlockRequestLimits(options) {
|
|
|
729
803
|
count('request_too_large', address);
|
|
730
804
|
if (state.refusal)
|
|
731
805
|
count(state.refusal.reason, address);
|
|
732
|
-
|
|
806
|
+
let fields = state.refusal?.fields ??
|
|
733
807
|
(state.tightest && (route === 'mcp' || route === 'a2a' || route === 'rest')
|
|
734
808
|
? fieldsOf(state.tightest, false)
|
|
735
809
|
: undefined);
|
|
810
|
+
// The tenant's monthly quotas follow the bucket the answer reports, when nothing refused.
|
|
811
|
+
if (state.quota && !state.refusal) {
|
|
812
|
+
const join = (first, rest) => [...(first ? [first] : []), ...rest].join(', ');
|
|
813
|
+
fields = {
|
|
814
|
+
...fields,
|
|
815
|
+
RateLimit: join(fields?.RateLimit, state.quota.rateLimit),
|
|
816
|
+
'RateLimit-Policy': join(fields?.['RateLimit-Policy'], state.quota.policy),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
736
819
|
if (!fields)
|
|
737
820
|
return response;
|
|
738
821
|
// A response's own headers may be immutable (Response.redirect): answer with a copy.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** What a resource is, as a hint for filtering and display; `other` when not said. */
|
|
2
|
+
export declare const SLOTLOCK_RESOURCE_KINDS: readonly ["person", "room", "desk", "space", "vehicle", "machine", "equipment", "service", "other"];
|
|
3
|
+
export type SlotlockResourceKind = (typeof SLOTLOCK_RESOURCE_KINDS)[number];
|
|
4
|
+
/** The profile's bounds, which the database also holds (ddl.ts). */
|
|
5
|
+
export declare const SLOTLOCK_RESOURCE_PROFILE_LIMITS: Readonly<{
|
|
6
|
+
/** Characters in a name. */
|
|
7
|
+
name: 200;
|
|
8
|
+
/** Characters in a description. */
|
|
9
|
+
description: 2000;
|
|
10
|
+
/** The most people or seats a resource is said to hold. */
|
|
11
|
+
capacity: 100000;
|
|
12
|
+
/** Labels on one resource. */
|
|
13
|
+
labels: 20;
|
|
14
|
+
/** Keys in its attributes. */
|
|
15
|
+
attributes: 50;
|
|
16
|
+
/** Characters in an attribute's key. */
|
|
17
|
+
attributeKey: 64;
|
|
18
|
+
/** Characters in an attribute's text value. */
|
|
19
|
+
attributeValue: 1000;
|
|
20
|
+
/** Bytes of its attributes as compact JSON. */
|
|
21
|
+
attributeBytes: 4096;
|
|
22
|
+
}>;
|
|
23
|
+
/** A label: lower case letters, digits, `.`, `_` and `-`, starting with a letter or digit, 1-64. */
|
|
24
|
+
export declare const SLOTLOCK_RESOURCE_LABEL: RegExp;
|
|
25
|
+
/** What an integrator keeps on a resource and wants back: text, numbers and true or false. */
|
|
26
|
+
export type SlotlockResourceAttributes = Record<string, string | number | boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* A resource's profile as it is set: a field left out is kept as it is. `null` clears a name, a
|
|
29
|
+
* description or a capacity; `labels: []` and `attributes: {}` clear those, and `kind: 'other'`
|
|
30
|
+
* says nothing about what it is.
|
|
31
|
+
*/
|
|
32
|
+
export interface SlotlockResourceProfileInput {
|
|
33
|
+
name?: string | null;
|
|
34
|
+
description?: string | null;
|
|
35
|
+
kind?: SlotlockResourceKind;
|
|
36
|
+
capacity?: number | null;
|
|
37
|
+
labels?: readonly string[];
|
|
38
|
+
attributes?: Readonly<SlotlockResourceAttributes>;
|
|
39
|
+
}
|
|
40
|
+
/** A profile input that passed its checks: names trimmed, labels sorted without repeats. */
|
|
41
|
+
export interface SlotlockResourceProfileChange {
|
|
42
|
+
name?: string | null;
|
|
43
|
+
description?: string | null;
|
|
44
|
+
kind?: SlotlockResourceKind;
|
|
45
|
+
capacity?: number | null;
|
|
46
|
+
labels?: string[];
|
|
47
|
+
attributes?: SlotlockResourceAttributes;
|
|
48
|
+
}
|
|
49
|
+
/** The profile fields, as the feed and the API name them. */
|
|
50
|
+
export declare const SLOTLOCK_RESOURCE_PROFILE_FIELDS: readonly ["name", "description", "kind", "capacity", "labels", "attributes"];
|
|
51
|
+
/**
|
|
52
|
+
* Check a profile change and put it in the form it is stored in: refused with
|
|
53
|
+
* `invalid_resource_profile`, naming the field, when a field is outside its bounds.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseSlotlockResourceProfile(input: SlotlockResourceProfileInput): SlotlockResourceProfileChange;
|
|
56
|
+
/** Whether a resource's labels include every one of `wanted`. */
|
|
57
|
+
export declare function slotlockResourceHasLabels(labels: readonly string[] | undefined, wanted: readonly string[]): boolean;
|
|
58
|
+
/** Locations a tenant may have. */
|
|
59
|
+
export declare const SLOTLOCK_MAX_LOCATIONS = 1000;
|
|
60
|
+
/** Levels a location tree may have: a region, a site, a building, a floor, a zone. */
|
|
61
|
+
export declare const SLOTLOCK_MAX_LOCATION_DEPTH = 5;
|
|
62
|
+
/** Groups a tenant may have. */
|
|
63
|
+
export declare const SLOTLOCK_MAX_RESOURCE_GROUPS = 500;
|
|
64
|
+
/** Groups one resource may be in. */
|
|
65
|
+
export declare const SLOTLOCK_MAX_GROUPS_PER_RESOURCE = 20;
|
|
66
|
+
/** A location's or a group's reference, as a pool's: 1-100 of a-z, 0-9, `.`, `_` and `-`. */
|
|
67
|
+
export declare const SLOTLOCK_GROUPING_REF: RegExp;
|
|
68
|
+
/** A place a tenant's resources are at. */
|
|
69
|
+
export interface SlotlockLocation {
|
|
70
|
+
ref: string;
|
|
71
|
+
name: string;
|
|
72
|
+
/** The location it is in; null at the top. */
|
|
73
|
+
parentRef: string | null;
|
|
74
|
+
/** The time zone a resource created here without its own takes; null to take its parent's. */
|
|
75
|
+
timezone: string | null;
|
|
76
|
+
/** How many resources are at it (not counting those below it). */
|
|
77
|
+
resources: number;
|
|
78
|
+
}
|
|
79
|
+
/** A set of a tenant's resources. */
|
|
80
|
+
export interface SlotlockResourceGroup {
|
|
81
|
+
ref: string;
|
|
82
|
+
name: string;
|
|
83
|
+
description: string | null;
|
|
84
|
+
/** How many resources are in it. */
|
|
85
|
+
members: number;
|
|
86
|
+
}
|
|
87
|
+
/** Check a location as it is to be stored: refused with `invalid_location`, naming the field. */
|
|
88
|
+
export declare function parseSlotlockLocation(input: {
|
|
89
|
+
ref: string;
|
|
90
|
+
name?: string;
|
|
91
|
+
parentRef?: string | null;
|
|
92
|
+
timezone?: string | null;
|
|
93
|
+
}): {
|
|
94
|
+
ref: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
parentRef?: string | null;
|
|
97
|
+
timezone?: string | null;
|
|
98
|
+
};
|
|
99
|
+
/** Check a group as it is to be stored: refused with `invalid_group`, naming the field. */
|
|
100
|
+
export declare function parseSlotlockResourceGroup(input: {
|
|
101
|
+
ref: string;
|
|
102
|
+
name: string;
|
|
103
|
+
description?: string | null;
|
|
104
|
+
}): {
|
|
105
|
+
ref: string;
|
|
106
|
+
name: string;
|
|
107
|
+
description: string | null;
|
|
108
|
+
};
|
|
109
|
+
/** Check a location or group reference given to find one: refused as its own kind's. */
|
|
110
|
+
export declare function slotlockGroupingRef(code: 'invalid_location' | 'invalid_group', value: unknown): string;
|
|
111
|
+
/**
|
|
112
|
+
* The order a pool booking tries a pool's members in (RFC 0003 G7): `stable`, by id (the default);
|
|
113
|
+
* `least_booked`, fewest bookings made on each in the last 30 days first, to share them out;
|
|
114
|
+
* `round_robin`, from the member after the one booked last.
|
|
115
|
+
*/
|
|
116
|
+
export declare const SLOTLOCK_POOL_ORDERS: readonly ["stable", "least_booked", "round_robin"];
|
|
117
|
+
/** Pools a tenant may set an order for (`pool_order_limit_reached` for one more). */
|
|
118
|
+
export declare const SLOTLOCK_MAX_POOL_SETTINGS = 1000;
|
|
119
|
+
/** The parts a composite resource may have (RFC 0003 G8): a hall of its rooms, a court of its halves. */
|
|
120
|
+
export declare const SLOTLOCK_MAX_COMPOSITE_PARTS = 50;
|
|
121
|
+
/**
|
|
122
|
+
* How far a partner's count of a day may differ from Slotlock's before it is a mismatch, in percent
|
|
123
|
+
* (RFC 0003 P5: a difference above 0.5% opens an incident on our side).
|
|
124
|
+
*/
|
|
125
|
+
export declare const SLOTLOCK_RECONCILE_TOLERANCE_PERCENT = 0.5;
|
|
126
|
+
export type SlotlockPoolOrder = (typeof SLOTLOCK_POOL_ORDERS)[number];
|
|
127
|
+
/** Locations, and groups, a key limited to part of its tenant may name (RFC 0003 G6), each. */
|
|
128
|
+
export declare const SLOTLOCK_MAX_KEY_SCOPE_REFS = 20;
|
|
129
|
+
/** A key limited to part of its tenant (RFC 0003 G6): the locations and groups it reaches. */
|
|
130
|
+
export interface SlotlockResourceScope {
|
|
131
|
+
locations: readonly string[];
|
|
132
|
+
groups: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
/** Whether `value` is a resource scope: location and group references, at least one, each list bounded. */
|
|
135
|
+
export declare function isSlotlockResourceScope(value: unknown): value is SlotlockResourceScope;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// A resource's profile (RFC 0003 G1): how people and agents tell resources apart and find them,
|
|
2
|
+
// whatever they are (a person, a room, a vehicle, a machine). Every field is optional, and none
|
|
3
|
+
// changes how a resource is booked: `kind` is a hint for filtering and `capacity` is informational,
|
|
4
|
+
// as SPEC.md requires ("no semantic rule assumes a particular industry"). Names and descriptions are
|
|
5
|
+
// calendar content: the feed says which profile fields changed, never their values.
|
|
6
|
+
/** What a resource is, as a hint for filtering and display; `other` when not said. */
|
|
7
|
+
export const SLOTLOCK_RESOURCE_KINDS = Object.freeze([
|
|
8
|
+
'person',
|
|
9
|
+
'room',
|
|
10
|
+
'desk',
|
|
11
|
+
'space',
|
|
12
|
+
'vehicle',
|
|
13
|
+
'machine',
|
|
14
|
+
'equipment',
|
|
15
|
+
'service',
|
|
16
|
+
'other',
|
|
17
|
+
]);
|
|
18
|
+
/** The profile's bounds, which the database also holds (ddl.ts). */
|
|
19
|
+
export const SLOTLOCK_RESOURCE_PROFILE_LIMITS = Object.freeze({
|
|
20
|
+
/** Characters in a name. */
|
|
21
|
+
name: 200,
|
|
22
|
+
/** Characters in a description. */
|
|
23
|
+
description: 2_000,
|
|
24
|
+
/** The most people or seats a resource is said to hold. */
|
|
25
|
+
capacity: 100_000,
|
|
26
|
+
/** Labels on one resource. */
|
|
27
|
+
labels: 20,
|
|
28
|
+
/** Keys in its attributes. */
|
|
29
|
+
attributes: 50,
|
|
30
|
+
/** Characters in an attribute's key. */
|
|
31
|
+
attributeKey: 64,
|
|
32
|
+
/** Characters in an attribute's text value. */
|
|
33
|
+
attributeValue: 1_000,
|
|
34
|
+
/** Bytes of its attributes as compact JSON. */
|
|
35
|
+
attributeBytes: 4_096,
|
|
36
|
+
});
|
|
37
|
+
/** A label: lower case letters, digits, `.`, `_` and `-`, starting with a letter or digit, 1-64. */
|
|
38
|
+
export const SLOTLOCK_RESOURCE_LABEL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
39
|
+
/** The profile fields, as the feed and the API name them. */
|
|
40
|
+
export const SLOTLOCK_RESOURCE_PROFILE_FIELDS = Object.freeze([
|
|
41
|
+
'name',
|
|
42
|
+
'description',
|
|
43
|
+
'kind',
|
|
44
|
+
'capacity',
|
|
45
|
+
'labels',
|
|
46
|
+
'attributes',
|
|
47
|
+
]);
|
|
48
|
+
function invalid(field, message) {
|
|
49
|
+
return Object.assign(new Error(`Slotlock resource ${field} ${message}`), {
|
|
50
|
+
code: 'invalid_resource_profile',
|
|
51
|
+
field,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// C0 and C1 control characters, which no name shows; a description may still break lines.
|
|
55
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
56
|
+
const CONTROL_BUT_LINES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/;
|
|
57
|
+
/** Characters as PostgreSQL's char_length counts them: code points, not UTF-16 units. */
|
|
58
|
+
const characters = (value) => [...value].length;
|
|
59
|
+
function text(field, value, max, lines) {
|
|
60
|
+
if (value === null)
|
|
61
|
+
return null;
|
|
62
|
+
if (typeof value !== 'string')
|
|
63
|
+
throw invalid(field, 'must be text or null');
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
if (trimmed.length === 0)
|
|
66
|
+
throw invalid(field, 'must not be empty: send null to clear it');
|
|
67
|
+
if (characters(trimmed) > max)
|
|
68
|
+
throw invalid(field, `must be at most ${max} characters`);
|
|
69
|
+
if ((lines ? CONTROL_BUT_LINES : CONTROL).test(trimmed)) {
|
|
70
|
+
throw invalid(field, 'must not contain control characters');
|
|
71
|
+
}
|
|
72
|
+
return trimmed;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check a profile change and put it in the form it is stored in: refused with
|
|
76
|
+
* `invalid_resource_profile`, naming the field, when a field is outside its bounds.
|
|
77
|
+
*/
|
|
78
|
+
export function parseSlotlockResourceProfile(input) {
|
|
79
|
+
const limits = SLOTLOCK_RESOURCE_PROFILE_LIMITS;
|
|
80
|
+
const change = {};
|
|
81
|
+
if (input.name !== undefined)
|
|
82
|
+
change.name = text('name', input.name, limits.name, false);
|
|
83
|
+
if (input.description !== undefined) {
|
|
84
|
+
change.description = text('description', input.description, limits.description, true);
|
|
85
|
+
}
|
|
86
|
+
if (input.kind !== undefined) {
|
|
87
|
+
if (!SLOTLOCK_RESOURCE_KINDS.includes(input.kind)) {
|
|
88
|
+
throw invalid('kind', `must be one of ${SLOTLOCK_RESOURCE_KINDS.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
change.kind = input.kind;
|
|
91
|
+
}
|
|
92
|
+
if (input.capacity !== undefined) {
|
|
93
|
+
if (input.capacity !== null &&
|
|
94
|
+
!(Number.isSafeInteger(input.capacity) && input.capacity >= 1 && input.capacity <= limits.capacity)) {
|
|
95
|
+
throw invalid('capacity', `must be a whole number from 1 to ${limits.capacity}, or null`);
|
|
96
|
+
}
|
|
97
|
+
change.capacity = input.capacity;
|
|
98
|
+
}
|
|
99
|
+
if (input.labels !== undefined) {
|
|
100
|
+
if (!Array.isArray(input.labels))
|
|
101
|
+
throw invalid('labels', 'must be a list');
|
|
102
|
+
const labels = [...new Set(input.labels)];
|
|
103
|
+
if (labels.length > limits.labels)
|
|
104
|
+
throw invalid('labels', `must be at most ${limits.labels}`);
|
|
105
|
+
for (const label of labels) {
|
|
106
|
+
if (typeof label !== 'string' || !SLOTLOCK_RESOURCE_LABEL.test(label)) {
|
|
107
|
+
throw invalid('labels', 'must each be 1-64 of a-z, 0-9, ".", "_" and "-", starting with a letter or digit');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
change.labels = labels.sort();
|
|
111
|
+
}
|
|
112
|
+
if (input.attributes !== undefined) {
|
|
113
|
+
const given = input.attributes;
|
|
114
|
+
if (given === null || typeof given !== 'object' || Array.isArray(given)) {
|
|
115
|
+
throw invalid('attributes', 'must be an object');
|
|
116
|
+
}
|
|
117
|
+
const entries = Object.entries(given);
|
|
118
|
+
if (entries.length > limits.attributes) {
|
|
119
|
+
throw invalid('attributes', `must have at most ${limits.attributes} keys`);
|
|
120
|
+
}
|
|
121
|
+
const attributes = {};
|
|
122
|
+
for (const [key, value] of entries) {
|
|
123
|
+
if (key.length === 0 || characters(key) > limits.attributeKey || CONTROL.test(key)) {
|
|
124
|
+
throw invalid('attributes', `keys must be 1-${limits.attributeKey} characters without control characters`);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === 'string') {
|
|
127
|
+
if (characters(value) > limits.attributeValue) {
|
|
128
|
+
throw invalid('attributes', `text values must be at most ${limits.attributeValue} characters`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (typeof value === 'number') {
|
|
132
|
+
if (!Number.isFinite(value))
|
|
133
|
+
throw invalid('attributes', 'numbers must be finite');
|
|
134
|
+
}
|
|
135
|
+
else if (typeof value !== 'boolean') {
|
|
136
|
+
throw invalid('attributes', 'values must be text, numbers, or true or false');
|
|
137
|
+
}
|
|
138
|
+
attributes[key] = value;
|
|
139
|
+
}
|
|
140
|
+
if (Buffer.byteLength(JSON.stringify(attributes), 'utf8') > limits.attributeBytes) {
|
|
141
|
+
throw invalid('attributes', `must be at most ${limits.attributeBytes} bytes as JSON`);
|
|
142
|
+
}
|
|
143
|
+
change.attributes = attributes;
|
|
144
|
+
}
|
|
145
|
+
return change;
|
|
146
|
+
}
|
|
147
|
+
/** Whether a resource's labels include every one of `wanted`. */
|
|
148
|
+
export function slotlockResourceHasLabels(labels, wanted) {
|
|
149
|
+
return wanted.every((label) => labels?.includes(label) === true);
|
|
150
|
+
}
|
|
151
|
+
// Where a resource is, and the sets it is in (RFC 0003 G2, G3): a location is a place in a tree of
|
|
152
|
+
// the tenant's own (one a resource), a group any set the tenant wants (many a resource). Neither
|
|
153
|
+
// changes how a resource is booked; a pool does that.
|
|
154
|
+
/** Locations a tenant may have. */
|
|
155
|
+
export const SLOTLOCK_MAX_LOCATIONS = 1_000;
|
|
156
|
+
/** Levels a location tree may have: a region, a site, a building, a floor, a zone. */
|
|
157
|
+
export const SLOTLOCK_MAX_LOCATION_DEPTH = 5;
|
|
158
|
+
/** Groups a tenant may have. */
|
|
159
|
+
export const SLOTLOCK_MAX_RESOURCE_GROUPS = 500;
|
|
160
|
+
/** Groups one resource may be in. */
|
|
161
|
+
export const SLOTLOCK_MAX_GROUPS_PER_RESOURCE = 20;
|
|
162
|
+
/** A location's or a group's reference, as a pool's: 1-100 of a-z, 0-9, `.`, `_` and `-`. */
|
|
163
|
+
export const SLOTLOCK_GROUPING_REF = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
164
|
+
function invalidGrouping(code, field, message) {
|
|
165
|
+
return Object.assign(new Error(`Slotlock ${code === 'invalid_location' ? 'location' : 'group'} ${field} ${message}`), {
|
|
166
|
+
code,
|
|
167
|
+
field,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function groupingRef(code, field, value) {
|
|
171
|
+
if (typeof value !== 'string' || !SLOTLOCK_GROUPING_REF.test(value)) {
|
|
172
|
+
throw invalidGrouping(code, field, 'must be 1-100 of a-z, 0-9, ".", "_" and "-", starting with a letter or digit');
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function groupingName(code, value) {
|
|
177
|
+
try {
|
|
178
|
+
const name = text('name', value, SLOTLOCK_RESOURCE_PROFILE_LIMITS.name, false);
|
|
179
|
+
if (name === null)
|
|
180
|
+
throw invalid('name', 'must be given');
|
|
181
|
+
return name;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
throw invalidGrouping(code, 'name', error.message.replace(/^Slotlock resource name /, ''));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Check a location as it is to be stored: refused with `invalid_location`, naming the field. */
|
|
188
|
+
export function parseSlotlockLocation(input) {
|
|
189
|
+
const location = {
|
|
190
|
+
ref: groupingRef('invalid_location', 'ref', input.ref),
|
|
191
|
+
};
|
|
192
|
+
if (input.name !== undefined)
|
|
193
|
+
location.name = groupingName('invalid_location', input.name);
|
|
194
|
+
if (input.parentRef !== undefined) {
|
|
195
|
+
location.parentRef = input.parentRef === null ? null : groupingRef('invalid_location', 'parent', input.parentRef);
|
|
196
|
+
if (location.parentRef === location.ref)
|
|
197
|
+
throw invalidGrouping('invalid_location', 'parent', 'must not be itself');
|
|
198
|
+
}
|
|
199
|
+
if (input.timezone !== undefined) {
|
|
200
|
+
if (input.timezone !== null) {
|
|
201
|
+
try {
|
|
202
|
+
if (typeof input.timezone !== 'string' || input.timezone.length > 100)
|
|
203
|
+
throw new Error('no');
|
|
204
|
+
new Intl.DateTimeFormat('en-GB', { timeZone: input.timezone }).format(0);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw invalidGrouping('invalid_location', 'timezone', 'must be an IANA time zone, or null');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
location.timezone = input.timezone;
|
|
211
|
+
}
|
|
212
|
+
return location;
|
|
213
|
+
}
|
|
214
|
+
/** Check a group as it is to be stored: refused with `invalid_group`, naming the field. */
|
|
215
|
+
export function parseSlotlockResourceGroup(input) {
|
|
216
|
+
let description = null;
|
|
217
|
+
if (input.description !== undefined && input.description !== null) {
|
|
218
|
+
try {
|
|
219
|
+
description = text('description', input.description, SLOTLOCK_RESOURCE_PROFILE_LIMITS.description, true);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw invalidGrouping('invalid_group', 'description', error.message.replace(/^Slotlock resource description /, ''));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ref: groupingRef('invalid_group', 'ref', input.ref),
|
|
227
|
+
name: groupingName('invalid_group', input.name),
|
|
228
|
+
description,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Check a location or group reference given to find one: refused as its own kind's. */
|
|
232
|
+
export function slotlockGroupingRef(code, value) {
|
|
233
|
+
return groupingRef(code, 'ref', value);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The order a pool booking tries a pool's members in (RFC 0003 G7): `stable`, by id (the default);
|
|
237
|
+
* `least_booked`, fewest bookings made on each in the last 30 days first, to share them out;
|
|
238
|
+
* `round_robin`, from the member after the one booked last.
|
|
239
|
+
*/
|
|
240
|
+
export const SLOTLOCK_POOL_ORDERS = Object.freeze(['stable', 'least_booked', 'round_robin']);
|
|
241
|
+
/** Pools a tenant may set an order for (`pool_order_limit_reached` for one more). */
|
|
242
|
+
export const SLOTLOCK_MAX_POOL_SETTINGS = 1_000;
|
|
243
|
+
/** The parts a composite resource may have (RFC 0003 G8): a hall of its rooms, a court of its halves. */
|
|
244
|
+
export const SLOTLOCK_MAX_COMPOSITE_PARTS = 50;
|
|
245
|
+
/**
|
|
246
|
+
* How far a partner's count of a day may differ from Slotlock's before it is a mismatch, in percent
|
|
247
|
+
* (RFC 0003 P5: a difference above 0.5% opens an incident on our side).
|
|
248
|
+
*/
|
|
249
|
+
export const SLOTLOCK_RECONCILE_TOLERANCE_PERCENT = 0.5;
|
|
250
|
+
/** Locations, and groups, a key limited to part of its tenant may name (RFC 0003 G6), each. */
|
|
251
|
+
export const SLOTLOCK_MAX_KEY_SCOPE_REFS = 20;
|
|
252
|
+
/** Whether `value` is a resource scope: location and group references, at least one, each list bounded. */
|
|
253
|
+
export function isSlotlockResourceScope(value) {
|
|
254
|
+
const scope = value;
|
|
255
|
+
const refs = (list) => Array.isArray(list) &&
|
|
256
|
+
list.length <= SLOTLOCK_MAX_KEY_SCOPE_REFS &&
|
|
257
|
+
list.every((ref) => typeof ref === 'string' && SLOTLOCK_GROUPING_REF.test(ref));
|
|
258
|
+
return (typeof scope === 'object' &&
|
|
259
|
+
scope !== null &&
|
|
260
|
+
refs(scope.locations) &&
|
|
261
|
+
refs(scope.groups) &&
|
|
262
|
+
scope.locations.length + scope.groups.length > 0);
|
|
263
|
+
}
|