slotlock 0.6.3 → 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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  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 +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
@@ -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
+ }
package/rest-api.d.ts CHANGED
@@ -61,6 +61,8 @@ export interface SlotlockOpenApiOperation {
61
61
  description: string;
62
62
  inputSchema: JsonSchema;
63
63
  outputSchema: JsonSchema;
64
+ /** An operation's own scope: a tool's, or the partner API's (else the application scope). */
65
+ scope?: string;
64
66
  }
65
67
  /** The OpenAPI 3.1 document for the REST routes, from the operations' own schemas. */
66
68
  export declare function slotlockOpenApiDocument(args: {
package/rest-api.js CHANGED
@@ -29,6 +29,12 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
29
29
  operation: 'slotlock_release_slots',
30
30
  refusals: [409],
31
31
  },
32
+ {
33
+ method: 'POST',
34
+ path: '/v1/slot-holds/{hold_id}/confirm',
35
+ operation: 'slotlock_confirm_slot',
36
+ refusals: [404, 409],
37
+ },
32
38
  { method: 'GET', path: '/v1/events', operation: 'slotlock_list_events', refusals: [404] },
33
39
  { method: 'POST', path: '/v1/events', operation: 'slotlock_create_event', refusals: [404, 409] },
34
40
  { method: 'GET', path: '/v1/events/{event_id}', operation: 'slotlock_get_event', refusals: [404] },
@@ -44,6 +50,18 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
44
50
  operation: 'slotlock_delete_event',
45
51
  refusals: [404, 409],
46
52
  },
53
+ {
54
+ method: 'POST',
55
+ path: '/v1/events/{event_id}/reschedule',
56
+ operation: 'slotlock_reschedule',
57
+ refusals: [404, 409],
58
+ },
59
+ {
60
+ method: 'POST',
61
+ path: '/v1/events/{event_id}/cancel',
62
+ operation: 'slotlock_cancel',
63
+ refusals: [404, 409],
64
+ },
47
65
  ]);
48
66
  /** The application operations' routes, served when the server has an application backend. */
49
67
  export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATION_OPERATIONS.map(({ method, path, name }) => ({
@@ -65,6 +83,7 @@ const PROBLEM_TITLES = Object.freeze({
65
83
  revision_conflict: 'The revision is stale',
66
84
  confirmation_required: 'A person must confirm this write',
67
85
  rate_limited: 'Too many requests',
86
+ quota_exceeded: 'The monthly quota is used up',
68
87
  authentication_required: 'Authentication required',
69
88
  forbidden: 'Not allowed',
70
89
  invalid_arguments: 'The arguments are not valid',
@@ -330,8 +349,8 @@ export function slotlockOpenApiDocument(args) {
330
349
  if (!operation)
331
350
  throw new Error(`Slotlock REST route ${route.path} names no operation`);
332
351
  add(route, openApiOperation(route, operation, {
333
- tag: 'Application',
334
- scope: SLOTLOCK_APPLICATION_SCOPE,
352
+ tag: operation.scope === 'partner' ? 'Partner' : 'Application',
353
+ scope: operation.scope === 'partner' ? 'partner' : SLOTLOCK_APPLICATION_SCOPE,
335
354
  responses: {
336
355
  '400': ERROR_RESPONSE,
337
356
  '401': ERROR_RESPONSE,
@@ -378,6 +397,10 @@ export function slotlockOpenApiDocument(args) {
378
397
  name: 'Application',
379
398
  description: 'What the application around the agents does. Needs the admin scope; never offered to agents.',
380
399
  },
400
+ {
401
+ name: 'Partner',
402
+ description: "What a partner reselling Slotlock does for its own users' accounts. Needs a partner key (`slk_partner_…`), whose `partner` scope reaches nothing else.",
403
+ },
381
404
  ]
382
405
  : []),
383
406
  ],
@@ -387,7 +410,7 @@ export function slotlockOpenApiDocument(args) {
387
410
  bearer: {
388
411
  type: 'http',
389
412
  scheme: 'bearer',
390
- description: 'A Slotlock API key (`slk_…`) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
413
+ description: 'A Slotlock API key (`slk_…`, or `slk_test_…` for a test environment), a partner key (`slk_partner_…`; with a `Slotlock-Account` field naming one of its accounts, where its contract allows, it acts as that account) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
391
414
  },
392
415
  },
393
416
  schemas: {
@@ -403,6 +426,48 @@ export function slotlockOpenApiDocument(args) {
403
426
  resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
404
427
  start: { type: 'string', description: 'An overlap: the start it refused.' },
405
428
  end: { type: 'string', description: 'An overlap: the end it refused.' },
429
+ busy_start: {
430
+ type: 'string',
431
+ description: 'An overlap: when the booking in the way makes the time busy, from (buffers included).',
432
+ },
433
+ busy_end: { type: 'string', description: 'An overlap: until when that booking makes it busy.' },
434
+ conflicting_hold_id: {
435
+ type: 'string',
436
+ description: 'An overlap with a hold of your own: its id, to book or release it.',
437
+ },
438
+ conflicting_event_id: {
439
+ type: 'string',
440
+ description: 'An overlap with an event of your own: its id, to move or cancel it.',
441
+ },
442
+ approval_id: {
443
+ type: 'string',
444
+ description: 'approval_pending, approval_declined or approval_expired: the approval a policy made of the booking. Retry with the same idempotency_key to learn how it was decided.',
445
+ },
446
+ policy_ref: { type: 'string', description: 'An approval refusal: the policy that applies.' },
447
+ expires_at: {
448
+ type: 'string',
449
+ description: 'approval_pending or approval_required: until when a person may decide (the time is held until then).',
450
+ },
451
+ reason: { type: 'string', description: 'approval_declined: what the person who declined it said, if anything.' },
452
+ alternatives: {
453
+ type: 'array',
454
+ maxItems: 3,
455
+ description: 'A refusal another time can answer (overlap, pool_full, a booking rule): the earliest free times of the same length, within 7 days of the time asked for, checked as slotlock_find_available_slots checks them. Suggestions, not holds. Empty when none was found.',
456
+ items: {
457
+ type: 'object',
458
+ required: ['resource_id', 'start', 'end'],
459
+ properties: {
460
+ resource_id: { type: 'string' },
461
+ start: { type: 'string', format: 'date-time' },
462
+ end: { type: 'string', format: 'date-time' },
463
+ resource_ids: {
464
+ type: 'array',
465
+ items: { type: 'string' },
466
+ description: 'A bundle or pool booking: the resources free together then.',
467
+ },
468
+ },
469
+ },
470
+ },
406
471
  issues: {
407
472
  type: 'array',
408
473
  description: '`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.',
package/sandbox.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { type SlotlockAgentCalendarBackend } from './agent-server.js';
2
+ import { type SlotlockMaintenanceResult } from './maintenance.js';
3
+ import type { SlotlockStore } from './store.js';
4
+ export declare const SLOTLOCK_SANDBOX_SCENARIOS: readonly ["car-rental", "clinic", "desks"];
5
+ export type SlotlockSandboxScenario = (typeof SLOTLOCK_SANDBOX_SCENARIOS)[number];
6
+ /** Agents one race runs at most, so a race stays a test of the server and not a load on it. */
7
+ export declare const SLOTLOCK_SANDBOX_MAX_RACERS = 200;
8
+ /** Refused outside a test environment. */
9
+ export declare class SlotlockSandboxError extends Error {
10
+ readonly code: 'sandbox_test_only' | 'sandbox_not_empty';
11
+ constructor(code: 'sandbox_test_only' | 'sandbox_not_empty', message: string);
12
+ }
13
+ export interface SlotlockSandboxSeedResult {
14
+ scenario: SlotlockSandboxScenario;
15
+ resources: number;
16
+ locations: number;
17
+ groups: number;
18
+ pools: number;
19
+ bookings: number;
20
+ }
21
+ /**
22
+ * Fill an empty test environment with a scenario, in one transaction: its locations, groups,
23
+ * resources (with profiles, pools and a composite where it has one), their hours and booking
24
+ * rules, and a few bookings tomorrow. Refused (`sandbox_not_empty`) where it has resources already:
25
+ * reset it first.
26
+ */
27
+ export declare function seedSlotlockSandbox(store: SlotlockStore, tenantRef: string, scenario: SlotlockSandboxScenario, options?: {
28
+ now?: Date;
29
+ }): Promise<SlotlockSandboxSeedResult>;
30
+ /** Empty a test environment: what seeding made, and every booking since. Keys, webhooks and plan stay. */
31
+ export declare function resetSlotlockSandbox(store: SlotlockStore, tenantRef: string): Promise<Record<string, number>>;
32
+ export interface SlotlockSandboxFastForwardResult {
33
+ minutes: number;
34
+ /** Holds (a waitlist offer's and an approval's among them) and approvals that lapsed now. */
35
+ holds: number;
36
+ approvals: number;
37
+ /** The maintenance pass that then reaped them, as it would have. */
38
+ maintenance: SlotlockMaintenanceResult;
39
+ }
40
+ /**
41
+ * Move a test environment's waiting forward: what would lapse within `minutes` lapses now, then a
42
+ * maintenance pass reaps it as it would have (holds released, waitlist offers passed on, approvals
43
+ * expired). Bookings and events keep their times; no-shows and series rolls follow the real clock.
44
+ */
45
+ export declare function fastForwardSlotlockSandbox(store: SlotlockStore, tenantRef: string, minutes: number): Promise<SlotlockSandboxFastForwardResult>;
46
+ export interface SlotlockSandboxRaceResult {
47
+ agents: number;
48
+ booked: number;
49
+ /** Refusals by code. */
50
+ refused: Record<string, number>;
51
+ /** Refusals that came with alternatives. */
52
+ withAlternatives: number;
53
+ serverErrors: number;
54
+ p50Ms: number;
55
+ p99Ms: number;
56
+ maxMs: number;
57
+ }
58
+ /**
59
+ * Race `agents` agents (2-200), each its own principal, for one time on one resource of a test
60
+ * environment, through the same operation boundary MCP, A2A and REST use: who booked, who was
61
+ * refused and why, and how long each answer took.
62
+ */
63
+ export declare function raceSlotlockSandbox(params: {
64
+ backend: SlotlockAgentCalendarBackend;
65
+ tenantRef: string;
66
+ resourceId: string;
67
+ start: Date;
68
+ end: Date;
69
+ agents?: number;
70
+ }): Promise<SlotlockSandboxRaceResult>;