slotlock 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/config.js CHANGED
@@ -97,15 +97,33 @@ export function readAvailability(env) {
97
97
  * (bidi, zero-width) or line separator character, `<` or `>`.
98
98
  */
99
99
  export const SLOTLOCK_DASHBOARD_NAME_PATTERN = /^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}<>]{1,60}$/u;
100
+ /**
101
+ * Setting any of these means serving the dashboard. SLOTLOCK_DASHBOARD_USERS is not one: alone, it
102
+ * names whose keys a server takes when the dashboard runs elsewhere (readDashboardUsers).
103
+ */
100
104
  const DASHBOARD_VARIABLES = [
101
105
  'SLOTLOCK_GITHUB_CLIENT_ID',
102
106
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
103
107
  'SLOTLOCK_SESSION_SECRET',
104
- 'SLOTLOCK_DASHBOARD_USERS',
105
108
  ];
106
109
  /**
107
- * The dashboard's settings, or undefined when none is set. Setting any of them means serving the
108
- * dashboard, so the rest are then required: a half-configured sign-in is a startup error.
110
+ * SLOTLOCK_DASHBOARD_USERS: `'*'`, GitHub user ids, or undefined when it is not set. A server whose
111
+ * dashboard runs elsewhere (a Worker) reads it too, so that removing a person there stops their keys.
112
+ */
113
+ export function readDashboardUsers(env) {
114
+ const users = optional(env, 'SLOTLOCK_DASHBOARD_USERS')?.replaceAll(' ', '');
115
+ if (users === undefined)
116
+ return undefined;
117
+ const person = '(?:[1-9]\\d{0,19}|google:[A-Za-z0-9._-]{1,255})';
118
+ if (users !== '*' && !new RegExp(`^${person}(,${person})*$`).test(users)) {
119
+ throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_USERS must be * for every account, or a comma-separated list of GitHub user ids (gh api users/<login> --jq .id) and Google accounts as google:<subject>');
120
+ }
121
+ return users === '*' ? '*' : [...new Set(users.split(','))];
122
+ }
123
+ /**
124
+ * The dashboard's settings, or undefined when none of DASHBOARD_VARIABLES is set. Setting any of
125
+ * them means serving the dashboard, so the rest, and SLOTLOCK_DASHBOARD_USERS, are then required: a
126
+ * half-configured sign-in is a startup error.
109
127
  */
110
128
  export function readDashboardConfig(env) {
111
129
  if (DASHBOARD_VARIABLES.every((name) => optional(env, name) === undefined))
@@ -118,6 +136,14 @@ export function readDashboardConfig(env) {
118
136
  if (githubClientSecret === undefined) {
119
137
  throw new SlotlockConfigError('SLOTLOCK_GITHUB_CLIENT_SECRET is required to serve the dashboard: a client secret of that GitHub OAuth app');
120
138
  }
139
+ const googleClientId = optional(env, 'SLOTLOCK_GOOGLE_CLIENT_ID');
140
+ const googleClientSecret = readSecret(env, 'SLOTLOCK_GOOGLE_CLIENT_SECRET');
141
+ if ((googleClientId === undefined) !== (googleClientSecret === undefined)) {
142
+ throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID and SLOTLOCK_GOOGLE_CLIENT_SECRET go together: an OAuth client of a Google Cloud project whose redirect URI is <SLOTLOCK_PUBLIC_URL>/dashboard/callback');
143
+ }
144
+ if (googleClientId !== undefined && !/^[A-Za-z0-9._-]{1,200}$/.test(googleClientId)) {
145
+ throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID must be a Google OAuth client id');
146
+ }
121
147
  const sessionSecret = readSecret(env, 'SLOTLOCK_SESSION_SECRET');
122
148
  if (sessionSecret === undefined) {
123
149
  throw new SlotlockConfigError(`SLOTLOCK_SESSION_SECRET is required to serve the dashboard: it signs sign-in sessions; ${SECRET_HINT}`);
@@ -126,12 +152,19 @@ export function readDashboardConfig(env) {
126
152
  env.SLOTLOCK_GITHUB_CLIENT_SECRET,
127
153
  env.SLOTLOCK_AUTH_TOKEN,
128
154
  env.SLOTLOCK_CONFIRMATION_SECRET,
155
+ ...(googleClientSecret !== undefined ? [googleClientSecret] : []),
129
156
  ];
130
157
  if (others.includes(sessionSecret)) {
131
158
  throw new SlotlockConfigError('SLOTLOCK_SESSION_SECRET must differ from every other secret');
132
159
  }
133
- const users = optional(env, 'SLOTLOCK_DASHBOARD_USERS')?.replaceAll(' ', '');
134
- if (users === undefined || (users !== '*' && !/^[1-9]\d{0,19}(,[1-9]\d{0,19})*$/.test(users))) {
160
+ let users;
161
+ try {
162
+ users = readDashboardUsers(env);
163
+ }
164
+ catch {
165
+ users = undefined;
166
+ }
167
+ if (users === undefined) {
135
168
  throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_USERS is required to serve the dashboard: * for every GitHub account, or a comma-separated list of GitHub user ids (gh api users/<login> --jq .id)');
136
169
  }
137
170
  const assistantSecret = readSecret(env, 'SLOTLOCK_ASSISTANT_SECRET');
@@ -139,26 +172,83 @@ export function readDashboardConfig(env) {
139
172
  [sessionSecret, ...others].includes(assistantSecret)) {
140
173
  throw new SlotlockConfigError('SLOTLOCK_ASSISTANT_SECRET must differ from every other secret');
141
174
  }
175
+ const previousSecrets = readAssistantPreviousSecrets(env, assistantSecret, [sessionSecret, ...others]);
142
176
  const privateUrls = optional(env, 'SLOTLOCK_ASSISTANT_PRIVATE_URLS') ?? 'off';
143
177
  if (privateUrls !== 'on' && privateUrls !== 'off') {
144
178
  throw new SlotlockConfigError('SLOTLOCK_ASSISTANT_PRIVATE_URLS must be on or off');
145
179
  }
146
180
  const orgs = readDashboardOrganizations(env);
181
+ if (Array.isArray(orgs) && googleClientId !== undefined) {
182
+ throw new SlotlockConfigError('SLOTLOCK_GOOGLE_CLIENT_ID does not go with a list of SLOTLOCK_DASHBOARD_ORGS: those organizations are GitHub\'s; use SLOTLOCK_DASHBOARD_ORGS=enabled to have both');
183
+ }
147
184
  const name = optional(env, 'SLOTLOCK_DASHBOARD_NAME')?.trim();
148
185
  if (name !== undefined && name !== '' && !SLOTLOCK_DASHBOARD_NAME_PATTERN.test(name)) {
149
186
  throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_NAME must be 1-60 characters, without <, > or control or invisible characters');
150
187
  }
188
+ const termsUrl = readPolicyUrl(env, 'SLOTLOCK_DASHBOARD_TERMS_URL');
189
+ const privacyUrl = readPolicyUrl(env, 'SLOTLOCK_DASHBOARD_PRIVACY_URL');
151
190
  return {
152
191
  githubClientId,
153
192
  githubClientSecret,
154
193
  sessionSecret,
155
- allowedUsers: users === '*' ? '*' : [...new Set(users.split(','))],
194
+ allowedUsers: users,
195
+ ...(googleClientId !== undefined && googleClientSecret !== undefined
196
+ ? { google: { clientId: googleClientId, clientSecret: googleClientSecret } }
197
+ : {}),
156
198
  ...(orgs !== undefined ? { organizations: orgs } : {}),
157
199
  ...(name !== undefined && name !== '' ? { name } : {}),
200
+ ...(termsUrl !== undefined ? { termsUrl } : {}),
201
+ ...(privacyUrl !== undefined ? { privacyUrl } : {}),
158
202
  ...(assistantSecret !== undefined ? { assistantSecret } : {}),
203
+ ...(previousSecrets.length > 0 ? { assistantPreviousSecrets: previousSecrets } : {}),
159
204
  ...(assistantSecret !== undefined && privateUrls === 'on' ? { assistantPrivateUrls: true } : {}),
160
205
  };
161
206
  }
207
+ /** Previous assistant secrets a deployment may keep (assistant.ts SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS). */
208
+ const MAX_ASSISTANT_PREVIOUS_SECRETS = 3;
209
+ /**
210
+ * SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS: up to three earlier values of SLOTLOCK_ASSISTANT_SECRET,
211
+ * comma-separated, newest first, each as strong as a secret must be and unlike every other secret.
212
+ * Only with SLOTLOCK_ASSISTANT_SECRET: without a current secret nothing would seal the keys again.
213
+ */
214
+ function readAssistantPreviousSecrets(env, current, others) {
215
+ const name = 'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS';
216
+ const value = env[name];
217
+ if (value === undefined || value.trim() === '')
218
+ return [];
219
+ if (current === undefined) {
220
+ throw new SlotlockConfigError(`${name} needs SLOTLOCK_ASSISTANT_SECRET: the secret keys are sealed again under`);
221
+ }
222
+ const secrets = value.split(',').map((part) => readSecret({ [name]: part }, name));
223
+ if (secrets.some((secret) => secret === undefined)) {
224
+ throw new SlotlockConfigError(`${name} must be comma-separated secrets, with no empty one`);
225
+ }
226
+ const listed = secrets;
227
+ if (listed.length > MAX_ASSISTANT_PREVIOUS_SECRETS) {
228
+ throw new SlotlockConfigError(`${name} takes at most ${String(MAX_ASSISTANT_PREVIOUS_SECRETS)} secrets`);
229
+ }
230
+ if (new Set(listed).size !== listed.length || listed.some((secret) => secret === current || others.includes(secret))) {
231
+ throw new SlotlockConfigError(`${name} must differ from each other, from SLOTLOCK_ASSISTANT_SECRET and from every other secret`);
232
+ }
233
+ return listed;
234
+ }
235
+ /** A policy page the sign-in page links: an https URL, or undefined when the setting is empty. */
236
+ function readPolicyUrl(env, name) {
237
+ const value = optional(env, name)?.trim();
238
+ if (value === undefined || value === '')
239
+ return undefined;
240
+ let url;
241
+ try {
242
+ url = new URL(value);
243
+ }
244
+ catch {
245
+ throw new SlotlockConfigError(`${name} must be an https URL`);
246
+ }
247
+ if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') {
248
+ throw new SlotlockConfigError(`${name} must be an https URL`);
249
+ }
250
+ return url.href;
251
+ }
162
252
  /**
163
253
  * The GitHub organization ids of SLOTLOCK_DASHBOARD_ORGS, or undefined when unset. Read on its own as
164
254
  * well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
@@ -168,8 +258,10 @@ export function readDashboardOrganizations(env) {
168
258
  const orgs = optional(env, 'SLOTLOCK_DASHBOARD_ORGS')?.replaceAll(' ', '');
169
259
  if (orgs === undefined)
170
260
  return undefined;
261
+ if (orgs === 'enabled')
262
+ return 'enabled';
171
263
  if (!/^[1-9]\d{0,19}(,[1-9]\d{0,19})*$/.test(orgs)) {
172
- throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_ORGS must be a comma-separated list of GitHub organization ids (gh api orgs/<login> --jq .id)');
264
+ throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_ORGS must be a comma-separated list of GitHub organization ids (gh api orgs/<login> --jq .id), or enabled');
173
265
  }
174
266
  return [...new Set(orgs.split(','))];
175
267
  }
@@ -372,3 +464,18 @@ export function readTrustedProxyHeader(env) {
372
464
  }
373
465
  return value.toLowerCase();
374
466
  }
467
+ /** The request field that carries SLOTLOCK_TRUSTED_PROXY_SECRET; not cf-…, which Cloudflare's rules may not set. */
468
+ export const SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD = 'x-slotlock-proxy-proof';
469
+ /**
470
+ * SLOTLOCK_TRUSTED_PROXY_SECRET: a value the proxy in front adds to every request in
471
+ * SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, such as a Cloudflare transform rule. With it, the trusted
472
+ * proxy field is believed only beside that value, so a process that reaches the server without
473
+ * passing the proxy (another container on its network) cannot choose its own client address.
474
+ */
475
+ export function readTrustedProxySecret(env, trustedProxyHeader) {
476
+ const value = readSecret(env, 'SLOTLOCK_TRUSTED_PROXY_SECRET');
477
+ if (value !== undefined && trustedProxyHeader === undefined) {
478
+ throw new SlotlockConfigError('SLOTLOCK_TRUSTED_PROXY_SECRET proves SLOTLOCK_TRUSTED_PROXY_HEADER: set both, or neither');
479
+ }
480
+ return value;
481
+ }
@@ -0,0 +1 @@
1
+ export declare const SLOTLOCK_DASHBOARD_ICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#111110\"/><g transform=\"translate(12.2 14.1) scale(0.465)\"><path fill=\"#F6F4EE\" d=\"M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z\"/><path fill=\"#FF5B2E\" d=\"M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z\"/></g></svg>\n";
@@ -0,0 +1,5 @@
1
+ // The Slotlock mark the dashboard serves as its favicon. A copy of site/public/favicon.svg, which a
2
+ // test keeps identical; the dashboard's Content-Security-Policy allows images from its own origin
3
+ // only, so the mark is served, not inlined.
4
+ export const SLOTLOCK_DASHBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#111110"/><g transform="translate(12.2 14.1) scale(0.465)"><path fill="#F6F4EE" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"/><path fill="#FF5B2E" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"/></g></svg>
5
+ `;
package/dashboard.d.ts CHANGED
@@ -1,19 +1,28 @@
1
- import type { SlotlockApiKeyStore } from './api-keys.js';
2
- import { type SlotlockAgentCalendarBackend, type SlotlockAgentOperation, type SlotlockAgentOperationDispatchOptions, type SlotlockAgentPrincipal } from './agent-server.js';
1
+ import type { SlotlockDashboardTeams } from './teams.js';
2
+ import { type SlotlockApiKeyStore } from './api-keys.js';
3
+ import { type SlotlockAgentCalendarBackend, type SlotlockAgentOperation, type SlotlockAgentOperationDispatchOptions, type SlotlockAgentPrincipal, type SlotlockAgentUsage } from './agent-server.js';
3
4
  import { type SlotlockAssistantStore } from './assistant.js';
4
5
  import { type SlotlockRate } from './config.js';
5
6
  import { type SlotlockBookingRules } from './booking-rules.js';
7
+ import { type SlotlockTenantPlan } from './plans.js';
8
+ import { type SlotlockLocation, type SlotlockResourceGroup, type SlotlockResourceProfileInput } from './resource-profile.js';
9
+ import { type SlotlockUsageRow } from './usage.js';
6
10
  import { type CreateWebhookEndpointResult, type SlotlockChange, type SlotlockWebhookEndpoint } from './event-feed.js';
7
11
  import type { SlotlockSql, SlotlockStore, SlotlockWaitlistEntry } from './store.js';
8
- import type { Interval, SlotlockAgendaEntry, SlotlockResource, SlotlockBookingRuleDefaults, WeeklyAvailabilityRule } from './types.js';
12
+ import { type SlotlockSandboxScenario } from './sandbox.js';
13
+ import type { Interval, SlotlockAgendaEntry, SlotlockApproval, SlotlockResource, SlotlockBookingRuleDefaults, WeeklyAvailabilityRule } from './types.js';
9
14
  /** Resources one dashboard tenant may hold. */
10
- export declare const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
15
+ export declare const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
11
16
  /**
12
17
  * A tenant's calendar resources, as the dashboard lists and adds them. Each write takes, last, the
13
18
  * person who made it (`github:<user id>`) as `actor`, to record on the event feed.
14
19
  */
15
20
  export interface SlotlockDashboardResources {
16
21
  list(tenantRef: string): Promise<SlotlockResource[]>;
22
+ /** How many resources the tenant has, archived ones included, as a resource limit counts them. */
23
+ count?(tenantRef: string): Promise<number>;
24
+ /** The tenant's metered usage from `from` to `to`, UTC days inclusive (usage.ts). */
25
+ usage?(tenantRef: string, from: string, to: string): Promise<SlotlockUsageRow[]>;
17
26
  /**
18
27
  * Create the resource, or re-zone the tenant's resource with that reference. Refuse a new one
19
28
  * (an error with `code: 'resource_limit_reached'`) once the tenant has
@@ -39,8 +48,84 @@ export interface SlotlockDashboardResources {
39
48
  setRules?(tenantRef: string, id: string, rules: SlotlockBookingRules | null, actor?: string): Promise<SlotlockResource | null>;
40
49
  /** The tenant's default booking rules and its pools', which a resource's own rules overlay. */
41
50
  ruleDefaults?(tenantRef: string): Promise<SlotlockBookingRuleDefaults>;
51
+ /**
52
+ * Change one of the tenant's resources' profile (resource-profile.ts): the fields given, keeping
53
+ * the rest. As setRules; `code: 'invalid_resource_profile'`, with the `field`.
54
+ */
55
+ setProfile?(tenantRef: string, id: string, profile: SlotlockResourceProfileInput, actor?: string): Promise<SlotlockResource | null>;
56
+ /**
57
+ * Make one of the tenant's resources a part of a composite (RFC 0003 G8), or no part. A refusal
58
+ * carries the store's `code`: `parent_not_found`, `composite_too_deep`, `composite_parts_limit`
59
+ * or `composite_busy`.
60
+ */
61
+ setParent?(tenantRef: string, id: string, parentId: string | null, actor?: string): Promise<SlotlockResource | null>;
42
62
  /** Put one of the tenant's resources in a pool, or none: as setRules; `code: 'invalid_pool'`. */
43
63
  setPool?(tenantRef: string, id: string, pool: string | null, actor?: string): Promise<SlotlockResource | null>;
64
+ /**
65
+ * The tenant's locations and groups (RFC 0003 G2, G3), and where each resource is in them. A
66
+ * refusal carries the store's `code`: `invalid_location` or `invalid_group` (with the `field`),
67
+ * `location_not_found`, `location_too_deep`, `location_cycle`, `location_limit_reached`,
68
+ * `location_in_use`, `group_not_found`, `group_limit_reached` or `resource_group_limit_reached`.
69
+ */
70
+ grouping?: {
71
+ /** Each under its parent, depth first, with how many resources are at each. */
72
+ locations(tenantRef: string): Promise<SlotlockLocation[]>;
73
+ /** Create the location with this reference, or change its name, parent and time zone. */
74
+ putLocation(tenantRef: string, location: {
75
+ ref: string;
76
+ name: string;
77
+ parentRef: string | null;
78
+ timezone: string | null;
79
+ }, actor?: string): Promise<SlotlockLocation>;
80
+ /** False when the tenant has no such location. */
81
+ deleteLocation(tenantRef: string, ref: string, actor?: string): Promise<boolean>;
82
+ /** By reference, with how many resources are in each. */
83
+ groups(tenantRef: string): Promise<SlotlockResourceGroup[]>;
84
+ /** Create the group with this reference, or change its name and description. */
85
+ putGroup(tenantRef: string, group: {
86
+ ref: string;
87
+ name: string;
88
+ description: string | null;
89
+ }, actor?: string): Promise<SlotlockResourceGroup>;
90
+ /** Delete a group, its resources left as they are: false when the tenant has no such group. */
91
+ deleteGroup(tenantRef: string, ref: string, actor?: string): Promise<boolean>;
92
+ /**
93
+ * Put one of the tenant's resources at a location (or none) and in exactly these groups, in one
94
+ * transaction: the resource as it then is, or null when it is not the tenant's.
95
+ */
96
+ place(tenantRef: string, id: string, place: {
97
+ location: string | null;
98
+ groups: readonly string[];
99
+ }, actor?: string): Promise<SlotlockResource | null>;
100
+ };
101
+ /**
102
+ * The tenant's approvals (RFC 0004 A4), newest first, and deciding one: a grant makes the booking
103
+ * as the agent asked for it; a refusal says why (approval_not_pending, overlap, a booking rule).
104
+ */
105
+ approvals?: {
106
+ list(tenantRef: string, limit: number): Promise<SlotlockApproval[]>;
107
+ decide(tenantRef: string, id: string, decision: 'grant' | 'decline', actor?: string, reason?: string): Promise<{
108
+ ok: true;
109
+ } | {
110
+ ok: false;
111
+ code: string;
112
+ }>;
113
+ };
114
+ /**
115
+ * The sandbox (RFC 0004 A6), for a test environment only: fill it with a scenario, empty it, and
116
+ * make what would lapse within some minutes lapse now. Each refuses a live tenant.
117
+ */
118
+ sandbox?: {
119
+ seed(tenantRef: string, scenario: SlotlockSandboxScenario): Promise<{
120
+ resources: number;
121
+ bookings: number;
122
+ }>;
123
+ reset(tenantRef: string): Promise<void>;
124
+ fastForward(tenantRef: string, minutes: number): Promise<{
125
+ holds: number;
126
+ approvals: number;
127
+ }>;
128
+ };
44
129
  /** The tenant's waitlist, oldest first, and leaving it (false when the entry is not the tenant's). */
45
130
  waitlist?: {
46
131
  list(tenantRef: string, limit: number): Promise<SlotlockWaitlistEntry[]>;
@@ -91,6 +176,18 @@ export interface SlotlockDashboardState {
91
176
  export interface SlotlockDashboardAssistantOptions {
92
177
  /** Seals each tenant's API key (SLOTLOCK_ASSISTANT_SECRET): 32+ random characters. */
93
178
  secret: string;
179
+ /**
180
+ * Secrets keys may still be sealed under (SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), newest first, at
181
+ * most SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS: a key one of them opens is used, and sealed again
182
+ * under `secret` in the same request, so changing the secret makes no owner enter a key again.
183
+ * Drop an old secret once every tenant has used the assistant since the change, or keys still
184
+ * sealed under it stop opening.
185
+ */
186
+ previousSecrets?: readonly string[];
187
+ /** Each operation the assistant runs, for usage metering (the agent server's `onUsage`). */
188
+ onUsage?: (usage: SlotlockAgentUsage) => void;
189
+ /** Each message a member sends that the assistant answers, for usage metering. Never awaited. */
190
+ onMessage?: (tenantRef: string) => void;
94
191
  backend: SlotlockAgentCalendarBackend;
95
192
  /**
96
193
  * As the agent server's: false refuses an operation. Every offered operation by default. It also
@@ -135,11 +232,70 @@ export interface SlotlockDashboardAssistantOptions {
135
232
  /** How long a conversation is kept after its last message: 2 hours by default. */
136
233
  conversationTtlMs?: number;
137
234
  }
235
+ /**
236
+ * Hosted Slotlock's plans (plans.ts) and their billing: a Plan page with the tenant's plan and use,
237
+ * Upgrade (to a checkout page) and Manage (to a billing portal). Off unless set.
238
+ */
239
+ export interface SlotlockDashboardBilling {
240
+ /** The tenant's plan (its own, or the default), or null when it has none (the server's limits apply). */
241
+ plan(tenantRef: string): Promise<SlotlockTenantPlan | null>;
242
+ /**
243
+ * Puts a tenant new to plans on the default plan before it adds a key, resource or webhook
244
+ * endpoint, while the default does not yet apply to every tenant (plans.ts).
245
+ */
246
+ enrol?(tenantRef: string): Promise<void>;
247
+ /** A checkout URL for the Developer plan, or the Team plan for an organization (with `teamPrices`). */
248
+ checkout(input: {
249
+ tenantRef: string;
250
+ interval: 'month' | 'year';
251
+ plan?: 'developer' | 'team';
252
+ customer: string | null;
253
+ successUrl: string;
254
+ cancelUrl: string;
255
+ }): Promise<string>;
256
+ /** A billing portal URL for the tenant's customer. */
257
+ portal(input: {
258
+ customer: string;
259
+ returnUrl: string;
260
+ }): Promise<string>;
261
+ /** The Developer plan's prices as shown, such as `$10`. */
262
+ prices: {
263
+ month: string;
264
+ year: string;
265
+ };
266
+ /**
267
+ * The Team plan's prices as shown (Teams on Cloud). With them, an organization's workspace is
268
+ * offered Team instead of Developer.
269
+ */
270
+ teamPrices?: {
271
+ month: string;
272
+ year: string;
273
+ };
274
+ /**
275
+ * Metered overage on Developer (SLOTLOCK_OVERAGE, overage.ts): its price as shown, such as `$2
276
+ * per 10,000 operations`, and the percent of the quota where use is refused.
277
+ */
278
+ overage?: {
279
+ price: string;
280
+ stopPercent: number;
281
+ };
282
+ /** Where a Custom (self-hosted) enquiry goes: an https or mailto URL. */
283
+ contact: string;
284
+ /** The https origins Upgrade and Manage redirect to, allowed in the policy's form-action. */
285
+ formOrigins: readonly string[];
286
+ }
138
287
  export interface SlotlockDashboardOptions {
139
288
  /** Where people reach the server; the dashboard lives at `<publicUrl>/dashboard`. */
140
289
  publicUrl: string;
141
290
  /** What the deployment is called in the dashboard: 1-60 characters. Defaults to `Slotlock`. */
142
291
  name?: string;
292
+ /**
293
+ * The terms people accept by signing in, an https URL. The sign-in page links them beside the
294
+ * button; without it, the page names no terms.
295
+ */
296
+ termsUrl?: string;
297
+ /** The privacy notice the sign-in page links, an https URL. */
298
+ privacyUrl?: string;
143
299
  /**
144
300
  * Where agents reach the server's MCP and A2A endpoints, which the dashboard shows, when it is
145
301
  * served from another origin (on Cloudflare Workers, with the server elsewhere). Defaults to
@@ -158,9 +314,25 @@ export interface SlotlockDashboardOptions {
158
314
  userUrl?: string;
159
315
  membershipsUrl?: string;
160
316
  };
317
+ /**
318
+ * Sign-in with Google (OpenID Connect), beside GitHub, for people without a GitHub account: each
319
+ * is their own tenant, `google:<subject>`. Not offered with a fixed list of `organizations`,
320
+ * which are GitHub's. Its redirect URI is the same `<publicUrl>/dashboard/callback`.
321
+ */
322
+ google?: {
323
+ clientId: string;
324
+ clientSecret: string;
325
+ authorizeUrl?: string;
326
+ tokenUrl?: string;
327
+ /** Google's signing keys (JWKS); https://www.googleapis.com/oauth2/v3/certs by default. */
328
+ jwksUrl?: string;
329
+ };
161
330
  /** Seals sessions and sign-in state and derives CSRF tokens: 32+ random characters. */
162
331
  sessionSecret: string;
163
- /** GitHub user ids (numbers, as strings) that may sign in, or `'*'` for every GitHub account. */
332
+ /**
333
+ * GitHub user ids (numbers, as strings) and Google accounts (`google:<subject>`) that may sign in,
334
+ * or `'*'` for every account.
335
+ */
164
336
  allowedUsers: '*' | readonly string[];
165
337
  /**
166
338
  * GitHub organization ids (numbers, as strings) this dashboard serves. With them, a tenant is an
@@ -171,8 +343,14 @@ export interface SlotlockDashboardOptions {
171
343
  * ends access at the next sign-in, at most a session (12 hours) later, and removing an
172
344
  * organization from this list signs its sessions out at once. Without it, each person is their
173
345
  * own tenant (`github:<id>`).
346
+ *
347
+ * `'enabled'` (Teams on Cloud, with `teams`): each person signs in to their own tenant and may
348
+ * switch to any organization they belong to that an owner turned on, on the Teams page; turning
349
+ * one off ends its use at once.
174
350
  */
175
- organizations?: readonly string[];
351
+ organizations?: readonly string[] | 'enabled';
352
+ /** The organizations turned on, with `organizations: 'enabled'` (teams.ts). */
353
+ teams?: SlotlockDashboardTeams;
176
354
  keys: SlotlockApiKeyStore;
177
355
  resources: SlotlockDashboardResources;
178
356
  /** Shared by every server behind this origin: see SlotlockDashboardState. */
@@ -181,6 +359,18 @@ export interface SlotlockDashboardOptions {
181
359
  availability?: readonly WeeklyAvailabilityRule[];
182
360
  /** The chat assistant; off unless set. */
183
361
  assistant?: SlotlockDashboardAssistantOptions;
362
+ /** Plans and billing (hosted Slotlock); off unless set. */
363
+ billing?: SlotlockDashboardBilling;
364
+ /**
365
+ * The deployment meters usage (SLOTLOCK_USAGE=on): the Plan page shows this month's, from the
366
+ * resources adapter's `usage`.
367
+ */
368
+ usage?: boolean;
369
+ /**
370
+ * The deployment holds tenants to their plans' monthly quotas (SLOTLOCK_QUOTAS=on): the Plan page
371
+ * shows this month's use against them, and warns from 80%. Needs `usage`.
372
+ */
373
+ quotas?: boolean;
184
374
  fetch?: typeof fetch;
185
375
  now?: () => number;
186
376
  /**
@@ -204,7 +394,13 @@ export interface SlotlockDashboard {
204
394
  * The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
205
395
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
206
396
  */
207
- export declare function createSlotlockDashboardResources(store: SlotlockStore): SlotlockDashboardResources;
397
+ export declare function createSlotlockDashboardResources(store: SlotlockStore, options?: {
398
+ /**
399
+ * The agent backend (`createSlotlockStoreAgentBackend`) a granted approval's booking is made
400
+ * through: with it, the dashboard lists approvals and decides them.
401
+ */
402
+ agentBackend?: SlotlockAgentCalendarBackend;
403
+ }): SlotlockDashboardResources;
208
404
  /**
209
405
  * Records accepted write confirmations (`SlotlockAgentConfirmationOptions.consumeAcceptance`) in
210
406
  * Postgres, shared by every server on the database: true the first time a confirmation's nonce is