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.
Files changed (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. 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
- /** 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) {
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, now) {
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, now);
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, now) });
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
- const checks = due.flatMap((kind) => [
539
- ...(application
540
- ? [{ name: `app_${kind}`, key: principal.tenantRef }]
541
- : [
542
- { name: `key_${kind}`, key: keyOf },
543
- { name: `tenant_${kind}`, key: principal.tenantRef },
544
- ]),
545
- { name: `global_${kind}`, key: '' },
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
- const fields = state.refusal?.fields ??
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
+ }