slotlock 0.9.0 → 0.10.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 +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/scim.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// SCIM 2.0 (RFC 7643, RFC 7644) as Okta and Microsoft Entra ID speak it (teams phase 5.6–5.7): the
|
|
2
|
+
// pure part, shared by both runtimes. Requests are normalised here before anything is applied, so
|
|
3
|
+
// the database sees one shape whichever provider sent it:
|
|
4
|
+
// - `op` in any case (Entra without `aadOptscim062020` sends "Replace");
|
|
5
|
+
// - booleans as JSON booleans or the strings "True"/"False" (Entra again);
|
|
6
|
+
// - a replace with no path whose value holds dotted or URN-qualified keys (Entra with the flag,
|
|
7
|
+
// Okta's deactivation) expanded into one operation per attribute;
|
|
8
|
+
// - Okta's group rename, which carries the group's own id in its value, accepted;
|
|
9
|
+
// - Entra's member removal without the flag ({"op":"Remove","path":"members","value":[{value}]})
|
|
10
|
+
// read as removing those members, never as clearing the list;
|
|
11
|
+
// - a remove with no path refused (400 noTarget), as RFC 7644 §3.5.2.2 says.
|
|
12
|
+
// Attributes Slotlock does not keep (Entra's enterprise extension, phone numbers, …) are ignored.
|
|
13
|
+
export const SLOTLOCK_SCIM_CONTENT_TYPE = 'application/scim+json';
|
|
14
|
+
export const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User';
|
|
15
|
+
export const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group';
|
|
16
|
+
export const SCIM_PATCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp';
|
|
17
|
+
export const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
|
|
18
|
+
export const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error';
|
|
19
|
+
/** A page holds at most this many resources (ServiceProviderConfig's filter.maxResults). */
|
|
20
|
+
export const SLOTLOCK_SCIM_MAX_RESULTS = 100;
|
|
21
|
+
/** A request refused: its status and SCIM error type. */
|
|
22
|
+
export class SlotlockScimError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
scimType;
|
|
25
|
+
constructor(status, scimType, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.scimType = scimType;
|
|
29
|
+
this.name = 'SlotlockScimError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** An error body: `status` as a string, as RFC 7644 §3.12 has it. */
|
|
33
|
+
export function slotlockScimErrorBody(status, scimType, detail) {
|
|
34
|
+
return { schemas: [SCIM_ERROR_SCHEMA], status: String(status), ...(scimType ? { scimType } : {}), detail };
|
|
35
|
+
}
|
|
36
|
+
const invalid = (detail) => new SlotlockScimError(400, 'invalidValue', detail);
|
|
37
|
+
const textOf = (value, what, max = 254) => {
|
|
38
|
+
if (value === null || value === undefined)
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof value !== 'string' || value.length > max || /[\p{Cc}]/u.test(value))
|
|
41
|
+
throw invalid(`${what} must be text of at most ${max} characters`);
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return trimmed === '' ? null : trimmed;
|
|
44
|
+
};
|
|
45
|
+
/** A SCIM boolean: true or false, or the strings Entra sends ("True", "False"). */
|
|
46
|
+
export function slotlockScimBoolean(value) {
|
|
47
|
+
if (value === true || value === false)
|
|
48
|
+
return value;
|
|
49
|
+
if (typeof value === 'string' && /^(true|false)$/i.test(value.trim()))
|
|
50
|
+
return value.trim().toLowerCase() === 'true';
|
|
51
|
+
throw invalid('active must be true or false');
|
|
52
|
+
}
|
|
53
|
+
/** The work email of an `emails` value: type work, else primary, else the first. */
|
|
54
|
+
function workEmailOf(value) {
|
|
55
|
+
if (value === null || value === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
if (!Array.isArray(value))
|
|
58
|
+
throw invalid('emails must be a list');
|
|
59
|
+
const emails = value.filter((entry) => typeof entry === 'object' && entry !== null);
|
|
60
|
+
const chosen = emails.find((entry) => typeof entry.type === 'string' && entry.type.toLowerCase() === 'work') ??
|
|
61
|
+
emails.find((entry) => entry.primary === true || entry.primary === 'true' || entry.primary === 'True') ??
|
|
62
|
+
emails[0];
|
|
63
|
+
const address = textOf(chosen?.value, 'An email');
|
|
64
|
+
return address === null ? null : address.toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
/** A user as a POST or PUT body gives it (RFC 7643 §4.1): what Slotlock keeps, the rest ignored. */
|
|
67
|
+
export function slotlockScimUserFromBody(body) {
|
|
68
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
69
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'A user is a JSON object');
|
|
70
|
+
const user = body;
|
|
71
|
+
const userName = textOf(user.userName, 'userName');
|
|
72
|
+
if (userName === null)
|
|
73
|
+
throw invalid('userName is required');
|
|
74
|
+
const name = typeof user.name === 'object' && user.name !== null ? user.name : {};
|
|
75
|
+
return {
|
|
76
|
+
userName,
|
|
77
|
+
externalId: textOf(user.externalId, 'externalId', 255),
|
|
78
|
+
active: user.active === undefined ? true : slotlockScimBoolean(user.active),
|
|
79
|
+
givenName: textOf(name.givenName, 'name.givenName', 100),
|
|
80
|
+
familyName: textOf(name.familyName, 'name.familyName', 100),
|
|
81
|
+
displayName: textOf(user.displayName, 'displayName', 200),
|
|
82
|
+
workEmail: workEmailOf(user.emails),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** A group as a POST or PUT body gives it. */
|
|
86
|
+
export function slotlockScimGroupFromBody(body) {
|
|
87
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
88
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'A group is a JSON object');
|
|
89
|
+
const group = body;
|
|
90
|
+
const displayName = textOf(group.displayName, 'displayName', 200);
|
|
91
|
+
if (displayName === null)
|
|
92
|
+
throw invalid('displayName is required');
|
|
93
|
+
return { displayName, externalId: textOf(group.externalId, 'externalId', 255), members: membersOf(group.members) };
|
|
94
|
+
}
|
|
95
|
+
function membersOf(value) {
|
|
96
|
+
if (value === null || value === undefined)
|
|
97
|
+
return [];
|
|
98
|
+
if (!Array.isArray(value))
|
|
99
|
+
throw invalid('members must be a list');
|
|
100
|
+
const ids = [];
|
|
101
|
+
for (const entry of value) {
|
|
102
|
+
const id = typeof entry === 'object' && entry !== null ? entry.value : undefined;
|
|
103
|
+
if (typeof id !== 'string' || id.length === 0 || id.length > 64)
|
|
104
|
+
throw invalid('Each member is {"value": "<user id>"}');
|
|
105
|
+
ids.push(id);
|
|
106
|
+
}
|
|
107
|
+
return [...new Set(ids)];
|
|
108
|
+
}
|
|
109
|
+
/** A PATCH body's operations, normalised (see the top of this file). */
|
|
110
|
+
export function slotlockScimPatchOperations(body) {
|
|
111
|
+
if (typeof body !== 'object' || body === null)
|
|
112
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'A patch is a JSON object');
|
|
113
|
+
const patch = body;
|
|
114
|
+
const schemas = Array.isArray(patch.schemas) ? patch.schemas : [];
|
|
115
|
+
if (!schemas.includes(SCIM_PATCH_SCHEMA))
|
|
116
|
+
throw new SlotlockScimError(400, 'invalidSyntax', `A patch names the schema ${SCIM_PATCH_SCHEMA}`);
|
|
117
|
+
const operations = patch.Operations ?? patch.operations;
|
|
118
|
+
if (!Array.isArray(operations) || operations.length === 0 || operations.length > 100) {
|
|
119
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'A patch has 1 to 100 Operations');
|
|
120
|
+
}
|
|
121
|
+
return operations.map((operation) => {
|
|
122
|
+
if (typeof operation !== 'object' || operation === null)
|
|
123
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'Each operation is an object');
|
|
124
|
+
const { op, path, value } = operation;
|
|
125
|
+
const name = typeof op === 'string' ? op.toLowerCase() : '';
|
|
126
|
+
if (name !== 'add' && name !== 'replace' && name !== 'remove')
|
|
127
|
+
throw new SlotlockScimError(400, 'invalidSyntax', 'op is add, replace or remove');
|
|
128
|
+
if (path !== undefined && path !== null && (typeof path !== 'string' || path.length > 200))
|
|
129
|
+
throw new SlotlockScimError(400, 'invalidPath', 'path is text');
|
|
130
|
+
const target = typeof path === 'string' && path.trim() !== '' ? path.trim() : null;
|
|
131
|
+
if (name === 'remove' && target === null)
|
|
132
|
+
throw new SlotlockScimError(400, 'noTarget', 'A remove names its path');
|
|
133
|
+
return { op: name, path: target, value };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** A path without the core schema's URN prefix, in lowercase; null for an extension's (ignored). */
|
|
137
|
+
function corePath(path, schema) {
|
|
138
|
+
const lower = path.toLowerCase();
|
|
139
|
+
const prefix = `${schema.toLowerCase()}:`;
|
|
140
|
+
if (lower.startsWith(prefix))
|
|
141
|
+
return lower.slice(prefix.length);
|
|
142
|
+
if (lower.startsWith('urn:'))
|
|
143
|
+
return null;
|
|
144
|
+
return lower;
|
|
145
|
+
}
|
|
146
|
+
/** Each attribute a path-less add or replace sets: its keys as paths (dotted or URN-qualified). */
|
|
147
|
+
function expand(operation) {
|
|
148
|
+
if (operation.path !== null)
|
|
149
|
+
return [operation];
|
|
150
|
+
if (typeof operation.value !== 'object' || operation.value === null || Array.isArray(operation.value)) {
|
|
151
|
+
throw invalid('An add or replace without a path holds an object of attributes');
|
|
152
|
+
}
|
|
153
|
+
return Object.entries(operation.value).map(([key, value]) => ({ op: operation.op, path: key, value }));
|
|
154
|
+
}
|
|
155
|
+
/** A user changed by a patch, all of it or none (RFC 7644 §3.5.2: a patch is atomic). */
|
|
156
|
+
export function slotlockScimPatchUser(current, id, operations) {
|
|
157
|
+
const next = { ...current };
|
|
158
|
+
for (const operation of operations.flatMap(expand)) {
|
|
159
|
+
const path = corePath(operation.path, SCIM_USER_SCHEMA);
|
|
160
|
+
if (path === null || path === 'schemas' || path === 'meta')
|
|
161
|
+
continue;
|
|
162
|
+
const removing = operation.op === 'remove';
|
|
163
|
+
const value = removing ? null : operation.value;
|
|
164
|
+
switch (path) {
|
|
165
|
+
case 'id':
|
|
166
|
+
if (value !== id)
|
|
167
|
+
throw new SlotlockScimError(400, 'mutability', 'id is read-only');
|
|
168
|
+
break;
|
|
169
|
+
case 'active':
|
|
170
|
+
if (removing)
|
|
171
|
+
throw invalid('active cannot be removed');
|
|
172
|
+
next.active = slotlockScimBoolean(value);
|
|
173
|
+
break;
|
|
174
|
+
case 'username': {
|
|
175
|
+
const userName = textOf(value, 'userName');
|
|
176
|
+
if (userName === null)
|
|
177
|
+
throw invalid('userName is required');
|
|
178
|
+
next.userName = userName;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case 'externalid':
|
|
182
|
+
next.externalId = textOf(value, 'externalId', 255);
|
|
183
|
+
break;
|
|
184
|
+
case 'displayname':
|
|
185
|
+
next.displayName = textOf(value, 'displayName', 200);
|
|
186
|
+
break;
|
|
187
|
+
case 'name.givenname':
|
|
188
|
+
next.givenName = textOf(value, 'name.givenName', 100);
|
|
189
|
+
break;
|
|
190
|
+
case 'name.familyname':
|
|
191
|
+
next.familyName = textOf(value, 'name.familyName', 100);
|
|
192
|
+
break;
|
|
193
|
+
case 'name': {
|
|
194
|
+
const name = typeof value === 'object' && value !== null ? value : {};
|
|
195
|
+
next.givenName = removing ? null : textOf(name.givenName, 'name.givenName', 100);
|
|
196
|
+
next.familyName = removing ? null : textOf(name.familyName, 'name.familyName', 100);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'emails':
|
|
200
|
+
next.workEmail = removing ? null : workEmailOf(value);
|
|
201
|
+
break;
|
|
202
|
+
case 'emails[type eq "work"].value':
|
|
203
|
+
case 'emails[type eq "work"]': {
|
|
204
|
+
const address = path.endsWith('.value') ? textOf(value, 'An email') : workEmailOf(value === null ? null : [value]);
|
|
205
|
+
next.workEmail = address === null ? null : address.toLowerCase();
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
// Kept nowhere in Slotlock (title, phone numbers, addresses, locale, …): ignored.
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return next;
|
|
214
|
+
}
|
|
215
|
+
/** A group changed by a patch, all of it or none. */
|
|
216
|
+
export function slotlockScimPatchGroup(current, id, operations) {
|
|
217
|
+
const next = { ...current, members: [...current.members] };
|
|
218
|
+
for (const operation of operations.flatMap(expand)) {
|
|
219
|
+
const raw = operation.path;
|
|
220
|
+
const path = corePath(raw, SCIM_GROUP_SCHEMA);
|
|
221
|
+
if (path === null || path === 'schemas' || path === 'meta')
|
|
222
|
+
continue;
|
|
223
|
+
const member = /^members\[value eq "([^"\\]{1,64})"\]$/.exec(path)?.[1];
|
|
224
|
+
if (member !== undefined) {
|
|
225
|
+
if (operation.op !== 'remove')
|
|
226
|
+
throw new SlotlockScimError(400, 'invalidPath', 'A member filter only removes');
|
|
227
|
+
// The id as sent (ids are case-sensitive); the path was lowercased to match its shape only.
|
|
228
|
+
const sent = /\[value eq "([^"\\]{1,64})"\]/i.exec(raw)?.[1] ?? member;
|
|
229
|
+
next.members = next.members.filter((existing) => existing !== sent);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
switch (path) {
|
|
233
|
+
case 'id':
|
|
234
|
+
if (operation.value !== id)
|
|
235
|
+
throw new SlotlockScimError(400, 'mutability', 'id is read-only');
|
|
236
|
+
break;
|
|
237
|
+
case 'displayname': {
|
|
238
|
+
if (operation.op === 'remove')
|
|
239
|
+
throw invalid('displayName cannot be removed');
|
|
240
|
+
const displayName = textOf(operation.value, 'displayName', 200);
|
|
241
|
+
if (displayName === null)
|
|
242
|
+
throw invalid('displayName is required');
|
|
243
|
+
next.displayName = displayName;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'externalid':
|
|
247
|
+
next.externalId = operation.op === 'remove' ? null : textOf(operation.value, 'externalId', 255);
|
|
248
|
+
break;
|
|
249
|
+
case 'members': {
|
|
250
|
+
if (operation.op === 'replace') {
|
|
251
|
+
next.members = membersOf(operation.value);
|
|
252
|
+
}
|
|
253
|
+
else if (operation.op === 'add') {
|
|
254
|
+
next.members = [...new Set([...next.members, ...membersOf(operation.value)])];
|
|
255
|
+
}
|
|
256
|
+
else if (operation.value === undefined || operation.value === null) {
|
|
257
|
+
// RFC 7644: a remove of the whole attribute.
|
|
258
|
+
next.members = [];
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Entra without the flag: the members named, not the list.
|
|
262
|
+
const removed = new Set(membersOf(operation.value));
|
|
263
|
+
next.members = next.members.filter((existing) => !removed.has(existing));
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return next;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* A filter (RFC 7644 §3.4.2.2) as Okta and Entra send it: `eq` comparisons of the allowed
|
|
275
|
+
* attributes, joined by `and`. Anything else is 400 invalidFilter.
|
|
276
|
+
*/
|
|
277
|
+
export function slotlockScimFilter(filter, allowed) {
|
|
278
|
+
if (filter.length > 500)
|
|
279
|
+
throw new SlotlockScimError(400, 'invalidFilter', 'The filter is too long');
|
|
280
|
+
const comparisons = [];
|
|
281
|
+
let rest = filter.trim();
|
|
282
|
+
const comparison = /^([A-Za-z][A-Za-z0-9._:-]*(?:\[[A-Za-z]+ eq "[A-Za-z]+"\]\.[A-Za-z]+)?)\s+eq\s+"((?:[^"\\]|\\.){0,254})"/i;
|
|
283
|
+
for (;;) {
|
|
284
|
+
const match = comparison.exec(rest);
|
|
285
|
+
if (!match)
|
|
286
|
+
throw new SlotlockScimError(400, 'invalidFilter', 'Slotlock filters by eq, joined by and');
|
|
287
|
+
const attribute = corePath(match[1], SCIM_USER_SCHEMA) ?? '';
|
|
288
|
+
if (!allowed.includes(attribute))
|
|
289
|
+
throw new SlotlockScimError(400, 'invalidFilter', `Slotlock does not filter by ${match[1]}`);
|
|
290
|
+
let value;
|
|
291
|
+
try {
|
|
292
|
+
value = JSON.parse(`"${match[2]}"`);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new SlotlockScimError(400, 'invalidFilter', 'The filter value is not a valid string');
|
|
296
|
+
}
|
|
297
|
+
if (/\p{Cc}/u.test(value))
|
|
298
|
+
throw new SlotlockScimError(400, 'invalidFilter', 'The filter value holds a control character');
|
|
299
|
+
comparisons.push({ attribute, value });
|
|
300
|
+
rest = rest.slice(match[0].length).trim();
|
|
301
|
+
if (rest === '')
|
|
302
|
+
return comparisons;
|
|
303
|
+
const and = /^and\s+/i.exec(rest);
|
|
304
|
+
if (!and || comparisons.length >= 5)
|
|
305
|
+
throw new SlotlockScimError(400, 'invalidFilter', 'Slotlock filters by eq, joined by and');
|
|
306
|
+
rest = rest.slice(and[0].length);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** A user as SCIM answers it: every attribute Slotlock keeps, so a GET then PUT loses nothing. */
|
|
310
|
+
export function slotlockScimUserResource(id, user, meta) {
|
|
311
|
+
return {
|
|
312
|
+
schemas: [SCIM_USER_SCHEMA],
|
|
313
|
+
id,
|
|
314
|
+
...(user.externalId !== null ? { externalId: user.externalId } : {}),
|
|
315
|
+
userName: user.userName,
|
|
316
|
+
active: user.active,
|
|
317
|
+
...(user.givenName !== null || user.familyName !== null
|
|
318
|
+
? { name: { ...(user.givenName !== null ? { givenName: user.givenName } : {}), ...(user.familyName !== null ? { familyName: user.familyName } : {}) } }
|
|
319
|
+
: {}),
|
|
320
|
+
...(user.displayName !== null ? { displayName: user.displayName } : {}),
|
|
321
|
+
...(user.workEmail !== null ? { emails: [{ value: user.workEmail, type: 'work', primary: true }] } : {}),
|
|
322
|
+
meta: { resourceType: 'User', created: meta.created.toISOString(), lastModified: meta.lastModified.toISOString(), location: meta.location },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** A group as SCIM answers it, with its members unless asked not to. */
|
|
326
|
+
export function slotlockScimGroupResource(id, group, meta, withMembers = true) {
|
|
327
|
+
return {
|
|
328
|
+
schemas: [SCIM_GROUP_SCHEMA],
|
|
329
|
+
id,
|
|
330
|
+
displayName: group.displayName,
|
|
331
|
+
...(group.externalId !== null ? { externalId: group.externalId } : {}),
|
|
332
|
+
...(withMembers ? { members: group.members.map((member) => ({ value: member })) } : {}),
|
|
333
|
+
meta: { resourceType: 'Group', created: meta.created.toISOString(), lastModified: meta.lastModified.toISOString(), location: meta.location },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** A list response; an empty one too (Entra needs it), with integer counts. */
|
|
337
|
+
export function slotlockScimList(resources, totalResults, startIndex) {
|
|
338
|
+
return { schemas: [SCIM_LIST_SCHEMA], totalResults, startIndex, itemsPerPage: resources.length, Resources: resources };
|
|
339
|
+
}
|
|
340
|
+
/** startIndex and count as a page: startIndex below 1 read as 1, count clamped to 0–100. */
|
|
341
|
+
export function slotlockScimPage(query) {
|
|
342
|
+
const start = Number.parseInt(query.get('startIndex') ?? '1', 10);
|
|
343
|
+
const count = Number.parseInt(query.get('count') ?? String(SLOTLOCK_SCIM_MAX_RESULTS), 10);
|
|
344
|
+
return {
|
|
345
|
+
// Within PostgreSQL's integer: past the end answers an empty page, never an error.
|
|
346
|
+
startIndex: Number.isFinite(start) && start >= 1 ? Math.min(start, 2_000_000_000) : 1,
|
|
347
|
+
count: Number.isFinite(count) ? Math.min(Math.max(count, 0), SLOTLOCK_SCIM_MAX_RESULTS) : SLOTLOCK_SCIM_MAX_RESULTS,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** What Slotlock supports (RFC 7643 §5): PATCH, filters up to 100 results; no bulk, sort or ETags. */
|
|
351
|
+
export function slotlockScimServiceProviderConfig(documentationUri) {
|
|
352
|
+
return {
|
|
353
|
+
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
|
|
354
|
+
documentationUri,
|
|
355
|
+
patch: { supported: true },
|
|
356
|
+
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
|
357
|
+
filter: { supported: true, maxResults: SLOTLOCK_SCIM_MAX_RESULTS },
|
|
358
|
+
changePassword: { supported: false },
|
|
359
|
+
sort: { supported: false },
|
|
360
|
+
etag: { supported: false },
|
|
361
|
+
authenticationSchemes: [
|
|
362
|
+
{ type: 'oauthbearertoken', name: 'Bearer token', description: 'The SCIM token from the team’s Identity settings', primary: true },
|
|
363
|
+
],
|
|
364
|
+
meta: { resourceType: 'ServiceProviderConfig' },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** The resource types Slotlock serves, as a list. */
|
|
368
|
+
export function slotlockScimResourceTypes(base) {
|
|
369
|
+
const types = [
|
|
370
|
+
{ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], id: 'User', name: 'User', endpoint: '/Users', schema: SCIM_USER_SCHEMA, meta: { resourceType: 'ResourceType', location: `${base}/ResourceTypes/User` } },
|
|
371
|
+
{ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], id: 'Group', name: 'Group', endpoint: '/Groups', schema: SCIM_GROUP_SCHEMA, meta: { resourceType: 'ResourceType', location: `${base}/ResourceTypes/Group` } },
|
|
372
|
+
];
|
|
373
|
+
return slotlockScimList(types, types.length, 1);
|
|
374
|
+
}
|
|
375
|
+
const attribute = (name, type, extra = {}) => ({
|
|
376
|
+
name,
|
|
377
|
+
type,
|
|
378
|
+
multiValued: false,
|
|
379
|
+
required: false,
|
|
380
|
+
caseExact: false,
|
|
381
|
+
mutability: 'readWrite',
|
|
382
|
+
returned: 'default',
|
|
383
|
+
uniqueness: 'none',
|
|
384
|
+
...extra,
|
|
385
|
+
});
|
|
386
|
+
/** The schemas of what Slotlock keeps, as a list. */
|
|
387
|
+
export function slotlockScimSchemas(base) {
|
|
388
|
+
const schemas = [
|
|
389
|
+
{
|
|
390
|
+
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
|
|
391
|
+
id: SCIM_USER_SCHEMA,
|
|
392
|
+
name: 'User',
|
|
393
|
+
attributes: [
|
|
394
|
+
attribute('userName', 'string', { required: true, uniqueness: 'server' }),
|
|
395
|
+
attribute('externalId', 'string', { caseExact: true }),
|
|
396
|
+
attribute('active', 'boolean'),
|
|
397
|
+
attribute('displayName', 'string'),
|
|
398
|
+
attribute('name', 'complex', { subAttributes: [attribute('givenName', 'string'), attribute('familyName', 'string')] }),
|
|
399
|
+
attribute('emails', 'complex', { multiValued: true, subAttributes: [attribute('value', 'string'), attribute('type', 'string'), attribute('primary', 'boolean')] }),
|
|
400
|
+
],
|
|
401
|
+
meta: { resourceType: 'Schema', location: `${base}/Schemas/${SCIM_USER_SCHEMA}` },
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
|
|
405
|
+
id: SCIM_GROUP_SCHEMA,
|
|
406
|
+
name: 'Group',
|
|
407
|
+
attributes: [
|
|
408
|
+
attribute('displayName', 'string', { required: true, uniqueness: 'server' }),
|
|
409
|
+
attribute('externalId', 'string', { caseExact: true }),
|
|
410
|
+
attribute('members', 'complex', { multiValued: true, subAttributes: [attribute('value', 'string', { mutability: 'immutable' })] }),
|
|
411
|
+
],
|
|
412
|
+
meta: { resourceType: 'Schema', location: `${base}/Schemas/${SCIM_GROUP_SCHEMA}` },
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
return slotlockScimList(schemas, schemas.length, 1);
|
|
416
|
+
}
|
package/self-host.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfi
|
|
|
10
10
|
* (site/docs-src/self-host.md) document these. Not STRIPE_SECRET_KEY, which only the owner's
|
|
11
11
|
* `partner close` reads, to bill partners.
|
|
12
12
|
*/
|
|
13
|
-
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_USAGE", "SLOTLOCK_QUOTAS", "SLOTLOCK_OVERAGE", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_TRUSTED_PROXY_SECRET", "SLOTLOCK_KNOWN_CLIENTS", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_SERVICE_NAME", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_GOOGLE_CLIENT_ID", "SLOTLOCK_GOOGLE_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "SLOTLOCK_DASHBOARD_TERMS_URL", "SLOTLOCK_DASHBOARD_PRIVACY_URL", "HOST", "PORT"];
|
|
13
|
+
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_USAGE", "SLOTLOCK_QUOTAS", "SLOTLOCK_OVERAGE", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_TRUSTED_PROXY_SECRET", "SLOTLOCK_KNOWN_CLIENTS", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_SERVICE_NAME", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_GOOGLE_CLIENT_ID", "SLOTLOCK_GOOGLE_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_TEAM_WORKSPACES", "SLOTLOCK_DASHBOARD_NAME", "SLOTLOCK_DASHBOARD_TERMS_URL", "SLOTLOCK_DASHBOARD_PRIVACY_URL", "SLOTLOCK_INVITATION_EMAIL", "SLOTLOCK_INVITATION_EMAIL_KEY", "SLOTLOCK_INVITATION_EMAIL_FROM", "SLOTLOCK_VERIFIED_DOMAINS", "SLOTLOCK_DNS_RESOLVER", "SLOTLOCK_TEAM_SSO", "SLOTLOCK_SSO_SECRET", "SLOTLOCK_TEAM_SCIM", "HOST", "PORT"];
|
|
14
14
|
export interface SlotlockDatabaseConfig {
|
|
15
15
|
/** The role `serve` and `resource` connect as. */
|
|
16
16
|
databaseUrl: string;
|
|
@@ -60,6 +60,18 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
60
60
|
* keys may act for, whether it serves the dashboard or another server (a Worker) does.
|
|
61
61
|
*/
|
|
62
62
|
allowedUsers?: '*' | string[];
|
|
63
|
+
/**
|
|
64
|
+
* Team workspaces (SLOTLOCK_DASHBOARD_TEAM_WORKSPACES=on), whether this server serves the dashboard
|
|
65
|
+
* or a Worker does: their `team:<id>` keys act only while the workspace exists (workspace_active).
|
|
66
|
+
*/
|
|
67
|
+
teamWorkspaces?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Verified domains (SLOTLOCK_VERIFIED_DOMAINS=on), whether this server serves the dashboard or a
|
|
70
|
+
* Worker does: their daily check runs in this server's maintenance.
|
|
71
|
+
*/
|
|
72
|
+
verifiedDomains?: {
|
|
73
|
+
resolver: 'doh' | 'system';
|
|
74
|
+
};
|
|
63
75
|
/**
|
|
64
76
|
* Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
|
|
65
77
|
* (SLOTLOCK_REST_API: on unless off).
|
|
@@ -147,11 +159,12 @@ export interface SlotlockRunningServer {
|
|
|
147
159
|
}
|
|
148
160
|
/**
|
|
149
161
|
* Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
|
|
150
|
-
* `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
|
|
151
|
-
*
|
|
152
|
-
* SLOTLOCK_DASHBOARD_USERS is
|
|
162
|
+
* `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a `team:<id>`
|
|
163
|
+
* tenant only with SLOTLOCK_DASHBOARD_TEAM_WORKSPACES=on (and, as serve's authenticate asks, while
|
|
164
|
+
* it exists); a `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id;
|
|
165
|
+
* every tenant when SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
|
|
153
166
|
*/
|
|
154
|
-
export declare function slotlockServeAllowsTenant(config: Pick<SlotlockServeConfig, 'organizations' | 'allowedUsers'>, tenantRef: string): boolean;
|
|
167
|
+
export declare function slotlockServeAllowsTenant(config: Pick<SlotlockServeConfig, 'organizations' | 'allowedUsers' | 'teamWorkspaces'>, tenantRef: string): boolean;
|
|
155
168
|
/**
|
|
156
169
|
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
157
170
|
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
@@ -166,7 +179,7 @@ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: S
|
|
|
166
179
|
}): SlotlockStore;
|
|
167
180
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
168
181
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
169
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource parent <ref> (<parent-ref> | none)\n Make a resource a part of a composite (a room of a hall): booking\n the whole then takes every part, and a part's booking keeps the\n whole from being booked; none makes it no part\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours: its own (null when none) and\n what it inherits from its location or the tenant\n hours set <json> (--resource <ref> | --location <ref> | --all [--override | --matching])\n Give one resource, one location or the tenant weekly hours; a\n resource without its own inherits its location's, then the\n tenant's, then the server's; --override also clears resources'\n own hours, --matching only those the same as the tenant's were\n or as these (copies 0.8.0 made); [] closes\n hours clear (--resource <ref> | --location <ref> | --all)\n Clear those hours, so they inherit from further up\n closures list Print the tenant's closures and special days from yesterday on\n closures add <first-day> [<last-day>] --label <text> [--open HH:MM-HH:MM]\n [--resource <ref> | --location <ref>]\n Close whole days (YYYY-MM-DD), or with --open give them those hours\n instead, everywhere or for one location or resource\n closures delete <id> Remove a closure\n closures import <file> [--resource <ref> | --location <ref>] [--label <calendar name>]\n Close the days an iCalendar file of holidays names (each all-day\n event, for the next two years); importing the calendar again (by its\n name: --label, else its own) updates them and removes those it dropped\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n [--locations <ref,...>] [--groups <ref,...>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored. With --locations or --groups, the key reaches\n only the resources there (each location with those in it), read and\n write only\n [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n ... and hold the key below the server's limits: its live holds, the\n hours they hold, its requests a minute, and every booking it makes\n waiting for a person (the dashboard's Approvals)\n key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n Replace an active key's limits; none at all gives it the server's\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n sandbox seed <car-rental|clinic|desks>\n Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a\n scenario: locations, groups, resources, hours, rules and bookings\n sandbox reset Empty the test environment; its keys, webhooks and plan stay\n sandbox fast-forward <minutes>\n Make what would lapse in the test environment within that time lapse\n now (holds, offers, approvals), then run maintenance\n sandbox race <resource-ref> [--agents <n>] [--start <date-time>]\n Race agents (default 100) for one hour on a test resource and print who\n booked, who was refused and why, and how long the answers took\n partner create <id> <name> --contract <json | @file>\n Create a partner reselling Slotlock, or change its name and contract:\n its plans (limits and quotas), its accounts' key scopes and its prices\n (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)\n partner list Print the partners, one JSON object per line\n partner suspend <id> Suspend a partner: every account with it, and its keys stop\n partner unsuspend <id> Make a suspended partner active again\n partner key <id> <name> Create a partner key (slk_partner_\u2026) and print it once\n partner revoke-key <id> <key-id>\n Revoke one of a partner's keys at once\n partner customer <id> <cus_\u2026>\n The Stripe customer its monthly invoice goes to\n partner webhook <id> <https-url>\n Where usage.period_closed goes; prints its signing secret once\n billing daily Once a day (cron), as the schema owner: close last month for every\n active partner once it has been over for 6 hours, report overage\n when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the\n partners' reconciliation mismatches of the last 7 days; exits 1 when\n anything waits or differs. Safe to run again\n overage report [--month <YYYY-MM>] [--event <name>]\n With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's\n operations above its quota this month (or --month) not yet sent, as\n Billing Meter events (--event, default slotlock_operations), once\n (as the schema owner, with STRIPE_SECRET_KEY)\n partner close <id> [--month <YYYY-MM>]\n Close an ended month (default: last month): price its accounts' use\n with the contract, record the lines once, send usage.period_closed,\n and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe\n invoice; safe to run again\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is\n paid for (cancel the subscription first)\n plan status Print the deployment's default plan and the tenant's own (hosted)\n plan default (free | developer | none) [--from <date>]\n Put every tenant without a plan of its own on this one from --from\n (an ISO date or time; default now), or none for no default; a new\n tenant gets it at once (as the schema owner, like tenant suspend)\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API\n key and surface (slotlock.usage_daily, kept 400 days); off\n (default): record nothing\n SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its\n operations or bookings this month (quota_exceeded, 429); off\n (default): no quota\n SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying\n Developer tenant is refused only past that share of its\n operations, and the rest above its quota is billed (overage\n report); needs SLOTLOCK_QUOTAS=on\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from\n their Slotlock-Client field (any other: other; none sent: none)\n OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per\n call is sent to <endpoint>/v1/traces (off without it)\n OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces\n OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)\n OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key\n OTEL_SERVICE_NAME service.name of the spans (default slotlock)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,\n comma-separated, newest first: keys sealed under one still open\n and are sealed again under the current secret when next used\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with\n redirect URI .../dashboard/callback (not with organization ids)\n SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;\n or enabled: each person's own tenant, plus the organizations an\n owner turns on from the dashboard's Teams page\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the\n sign-in page (none named without it)\n SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
182
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource parent <ref> (<parent-ref> | none)\n Make a resource a part of a composite (a room of a hall): booking\n the whole then takes every part, and a part's booking keeps the\n whole from being booked; none makes it no part\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours: its own (null when none) and\n what it inherits from its location or the tenant\n hours set <json> (--resource <ref> | --location <ref> | --all [--override | --matching])\n Give one resource, one location or the tenant weekly hours; a\n resource without its own inherits its location's, then the\n tenant's, then the server's; --override also clears resources'\n own hours, --matching only those the same as the tenant's were\n or as these (copies 0.8.0 made); [] closes\n hours clear (--resource <ref> | --location <ref> | --all)\n Clear those hours, so they inherit from further up\n closures list Print the tenant's closures and special days from yesterday on\n closures add <first-day> [<last-day>] --label <text> [--open HH:MM-HH:MM]\n [--resource <ref> | --location <ref>]\n Close whole days (YYYY-MM-DD), or with --open give them those hours\n instead, everywhere or for one location or resource\n closures delete <id> Remove a closure\n closures import <file> [--resource <ref> | --location <ref>] [--label <calendar name>]\n Close the days an iCalendar file of holidays names (each all-day\n event, for the next two years); importing the calendar again (by its\n name: --label, else its own) updates them and removes those it dropped\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n [--locations <ref,...>] [--groups <ref,...>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored. With --locations or --groups, the key reaches\n only the resources there (each location with those in it), read and\n write only\n [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n ... and hold the key below the server's limits: its live holds, the\n hours they hold, its requests a minute, and every booking it makes\n waiting for a person (the dashboard's Approvals)\n key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n Replace an active key's limits; none at all gives it the server's\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n sandbox seed <car-rental|clinic|desks>\n Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a\n scenario: locations, groups, resources, hours, rules and bookings\n sandbox reset Empty the test environment; its keys, webhooks and plan stay\n sandbox fast-forward <minutes>\n Make what would lapse in the test environment within that time lapse\n now (holds, offers, approvals), then run maintenance\n sandbox race <resource-ref> [--agents <n>] [--start <date-time>]\n Race agents (default 100) for one hour on a test resource and print who\n booked, who was refused and why, and how long the answers took\n partner create <id> <name> --contract <json | @file>\n Create a partner reselling Slotlock, or change its name and contract:\n its plans (limits and quotas), its accounts' key scopes and its prices\n (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)\n partner list Print the partners, one JSON object per line\n partner suspend <id> Suspend a partner: every account with it, and its keys stop\n partner unsuspend <id> Make a suspended partner active again\n partner key <id> <name> Create a partner key (slk_partner_\u2026) and print it once\n partner revoke-key <id> <key-id>\n Revoke one of a partner's keys at once\n partner customer <id> <cus_\u2026>\n The Stripe customer its monthly invoice goes to\n partner webhook <id> <https-url>\n Where usage.period_closed goes; prints its signing secret once\n billing daily Once a day (cron), as the schema owner: close last month for every\n active partner once it has been over for 6 hours, report overage\n when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the\n partners' reconciliation mismatches of the last 7 days; exits 1 when\n anything waits or differs. Safe to run again\n overage report [--month <YYYY-MM>] [--event <name>]\n With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's\n operations above its quota this month (or --month) not yet sent, as\n Billing Meter events (--event, default slotlock_operations), once\n (as the schema owner, with STRIPE_SECRET_KEY)\n partner close <id> [--month <YYYY-MM>]\n Close an ended month (default: last month): price its accounts' use\n with the contract, record the lines once, send usage.period_closed,\n and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe\n invoice; safe to run again\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant export [--out <dir>]\n Print everything the tenant keeps, read at one moment, one JSON line a\n row ({\"table\": ..., \"row\": ...}); with --out, one <table>.ndjson file a\n table in that directory (new or empty). Secrets stay out\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is\n paid for (cancel the subscription first)\n audit verify Check the tenant's audit log chain: prints how many events held and\n the first that does not (exit 1 when one does not)\n audit export Print the tenant's audit log, newest first, one JSON object per line\n subject export (--email <address> | --holder <ref> | --contact <ref> | --reference <ref>)\n Print what the tenant keeps about one person, one JSON line per row:\n {\"table\": ..., \"row\": ...}, up to 10,000 rows a table\n subject erase (--email <address> | --holder <ref> | --contact <ref> | --reference <ref>)\n Take one person out of the tenant's data: their contacts, attendee\n and waitlist entries go; their references become an erased name;\n bookings stay as time taken. Prints what it changed, by table\n plan status Print the deployment's default plan and the tenant's own (hosted)\n plan default (free | developer | none) [--from <date>]\n Put every tenant without a plan of its own on this one from --from\n (an ISO date or time; default now), or none for no default; a new\n tenant gets it at once (as the schema owner, like tenant suspend)\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API\n key and surface (slotlock.usage_daily, kept 400 days); off\n (default): record nothing\n SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its\n operations or bookings this month (quota_exceeded, 429); off\n (default): no quota\n SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying\n Developer tenant is refused only past that share of its\n operations, and the rest above its quota is billed (overage\n report); needs SLOTLOCK_QUOTAS=on\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from\n their Slotlock-Client field (any other: other; none sent: none)\n OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per\n call is sent to <endpoint>/v1/traces (off without it)\n OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces\n OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)\n OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key\n OTEL_SERVICE_NAME service.name of the spans (default slotlock)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,\n comma-separated, newest first: keys sealed under one still open\n and are sealed again under the current secret when next used\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with\n redirect URI .../dashboard/callback (not with organization ids)\n SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;\n or enabled: each person's own tenant, plus the organizations an\n owner turns on from the dashboard's Teams page\n SLOTLOCK_DASHBOARD_TEAM_WORKSPACES on: anyone signed in creates team workspaces and invites\n people to them by email, each with a role (default off; not\n with organization ids)\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the\n sign-in page (none named without it)\n SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links\n SLOTLOCK_INVITATION_EMAIL resend or postmark: send team invitations by email (with team\n workspaces; without it, the person inviting sends the link)\n SLOTLOCK_INVITATION_EMAIL_KEY Resend's API key, or Postmark's server token\n SLOTLOCK_INVITATION_EMAIL_FROM The sender the provider verified: team@example.com or Name <...>\n SLOTLOCK_VERIFIED_DOMAINS on or off (default): team Owners verify their domain with a DNS\n record, and people at it may ask to join (with team workspaces)\n SLOTLOCK_DNS_RESOLVER doh (default: Cloudflare, checked by Google) or system: how a\n verified domain's record is looked up each day\n SLOTLOCK_TEAM_SSO on or off (default): each team signs in through its own identity\n provider over OpenID Connect (with verified domains)\n SLOTLOCK_SSO_SECRET 32+ characters: seals each team's client secret (with team SSO)\n SLOTLOCK_TEAM_SCIM on or off (default): each team's identity provider provisions its\n people over SCIM 2.0 at /dashboard/scim/v2 (with verified domains)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
170
183
|
/**
|
|
171
184
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
172
185
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|