slotlock 0.8.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
package/self-host.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // supplies it the same way and nothing is read from a file or a request. A missing or weak secret
4
4
  // stops a command before it connects anywhere, and no secret is written to a log line or an error.
5
5
  // The one exception is the API key `key create` prints: that output is the key's only copy.
6
- import { createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
6
+ import { createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
7
7
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
8
8
  import { parseArgs } from 'node:util';
9
9
  import postgres from 'postgres';
@@ -12,15 +12,22 @@ import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend,
12
12
  import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, SLOTLOCK_TEST_TENANT_SUFFIX, slotlockBearerCredential, slotlockLiveTenant, } from './api-keys.js';
13
13
  import { SLOTLOCK_SANDBOX_MAX_RACERS, SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
14
14
  import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, } from './dashboard.js';
15
- import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readDashboardUsers, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, readTrustedProxySecret, slotlockKeySubscriptions, } from './config.js';
15
+ import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readDashboardTeamWorkspaces, readDashboardUsers, readVerifiedDomains, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, readTrustedProxySecret, slotlockKeySubscriptions, } from './config.js';
16
16
  import { parseBookingRules } from './booking-rules.js';
17
17
  import { slotlockApplicationOperation, slotlockApplicationScope, } from './application-api.js';
18
18
  import { SLOTLOCK_ACCOUNT_FIELD, createSlotlockPartnerKey, createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, listSlotlockPartners, putSlotlockPartner, revokeSlotlockPartnerKey, setSlotlockPartnerBilling, setSlotlockPartnerStatus, slotlockPartnerContractJson, } from './partners.js';
19
19
  import { closeSlotlockPartnerMonth, slotlockClientLabel, slotlockPreviousMonth } from './partner-billing.js';
20
20
  import { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
21
+ import { createSlotlockDashboardWorkspaces, createSlotlockWorkspaceCheck } from './workspaces.js';
22
+ import { createSlotlockWorkspaceDomains, runSlotlockDomainChecks } from './workspace-domains.js';
23
+ import { slotlockDomainResolvers } from './domains.js';
24
+ import { createSlotlockSecretBox } from './oidc.js';
25
+ import { createSlotlockInvitationEmailSender } from './invitation-email.js';
21
26
  import { SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT, reportSlotlockOverage, slotlockMonthOf } from './overage.js';
22
27
  import { createSlotlockOtlpExporter, createSlotlockOtlpMetricsExporter, readSlotlockOtlpConfig, } from './otel.js';
23
- import { readFile } from 'node:fs/promises';
28
+ import { appendFile, mkdir, readdir, readFile } from 'node:fs/promises';
29
+ import { join as joinPath } from 'node:path';
30
+ import { readSlotlockHolidayCalendar } from './holiday-import.js';
24
31
  import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_QUOTA_FUNCTIONS, SLOTLOCK_TENANT_TABLES, SLOTLOCK_USAGE_FUNCTIONS, } from './ddl.js';
25
32
  import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
26
33
  import { createSlotlockNodeServer, } from './node-server.js';
@@ -31,7 +38,7 @@ import { isSlotlockResourceScope } from './resource-profile.js';
31
38
  import { createSlotlockQuotas, noteSlotlockQuotaThreshold, readSlotlockQuotaUse } from './quotas.js';
32
39
  import { createSlotlockRequestLimits } from './rate-limit.js';
33
40
  import { createSlotlockUsageMeter, recordSlotlockUsage } from './usage.js';
34
- import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
41
+ import { assertSlotlockSchemaCurrent, createSlotlockStore, slotlockAuditEventRecord, } from './store.js';
35
42
  import { parseAvailabilityRules } from './weekly-hours.js';
36
43
  export { SlotlockConfigError };
37
44
  /**
@@ -75,9 +82,18 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
75
82
  'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
76
83
  'SLOTLOCK_DASHBOARD_USERS',
77
84
  'SLOTLOCK_DASHBOARD_ORGS',
85
+ 'SLOTLOCK_DASHBOARD_TEAM_WORKSPACES',
78
86
  'SLOTLOCK_DASHBOARD_NAME',
79
87
  'SLOTLOCK_DASHBOARD_TERMS_URL',
80
88
  'SLOTLOCK_DASHBOARD_PRIVACY_URL',
89
+ 'SLOTLOCK_INVITATION_EMAIL',
90
+ 'SLOTLOCK_INVITATION_EMAIL_KEY',
91
+ 'SLOTLOCK_INVITATION_EMAIL_FROM',
92
+ 'SLOTLOCK_VERIFIED_DOMAINS',
93
+ 'SLOTLOCK_DNS_RESOLVER',
94
+ 'SLOTLOCK_TEAM_SSO',
95
+ 'SLOTLOCK_SSO_SECRET',
96
+ 'SLOTLOCK_TEAM_SCIM',
81
97
  'HOST',
82
98
  'PORT',
83
99
  ]);
@@ -114,7 +130,9 @@ const SERVED_OPERATIONS = new Set([
114
130
  'slotlock_get_free_busy',
115
131
  'slotlock_find_next_available',
116
132
  'slotlock_find_available_slots',
133
+ 'slotlock_find_available_ranges',
117
134
  'slotlock_hold_slots',
135
+ 'slotlock_hold_together',
118
136
  'slotlock_release_slots',
119
137
  'slotlock_book_together',
120
138
  'slotlock_book_pool',
@@ -355,6 +373,11 @@ export function readSlotlockServeConfig(env) {
355
373
  const organizations = readDashboardOrganizations(env);
356
374
  if (organizations !== undefined)
357
375
  config.organizations = organizations;
376
+ if (readDashboardTeamWorkspaces(env))
377
+ config.teamWorkspaces = true;
378
+ const verifiedDomains = readVerifiedDomains(env);
379
+ if (verifiedDomains !== undefined)
380
+ config.verifiedDomains = verifiedDomains;
358
381
  const allowedUsers = dashboard?.allowedUsers ?? readDashboardUsers(env);
359
382
  if (allowedUsers !== undefined)
360
383
  config.allowedUsers = allowedUsers;
@@ -607,13 +630,19 @@ function serveMetrics(target, metrics, path, token) {
607
630
  }
608
631
  /**
609
632
  * Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
610
- * `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
611
- * `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
612
- * SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
633
+ * `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a `team:<id>`
634
+ * tenant only with SLOTLOCK_DASHBOARD_TEAM_WORKSPACES=on (and, as serve's authenticate asks, while
635
+ * it exists); a `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id;
636
+ * every tenant when SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
613
637
  */
614
638
  export function slotlockServeAllowsTenant(config, tenantRef) {
615
639
  // A test environment (RFC 0003 G9) is let through exactly as its live tenant.
616
640
  const live = slotlockLiveTenant(tenantRef);
641
+ if (live.startsWith('team:'))
642
+ return config.teamWorkspaces === true;
643
+ // A person a team's identity provider made has no workspace of their own, so no keys of one.
644
+ if (live.startsWith('sso:'))
645
+ return false;
617
646
  const orgId = /^github-org:(\d+)$/.exec(live)?.[1];
618
647
  // With `enabled`, the database decides, as serve's authenticate asks (createSlotlockOrganizationCheck).
619
648
  if (orgId !== undefined)
@@ -818,6 +847,8 @@ export async function startSlotlockServer(config, log) {
818
847
  : undefined;
819
848
  // Teams on Cloud: an organization's keys work while an owner has it turned on.
820
849
  const organizationEnabled = config.organizations === 'enabled' ? createSlotlockOrganizationCheck(sql) : null;
850
+ // Team workspaces: a team's keys work while it exists.
851
+ const teamActive = config.teamWorkspaces ? createSlotlockWorkspaceCheck(sql) : null;
821
852
  const limits = createSlotlockRequestLimits({
822
853
  limits: config.limits,
823
854
  ...(config.sharedLimits
@@ -853,6 +884,8 @@ export async function startSlotlockServer(config, log) {
853
884
  const orgId = organizationEnabled ? /^github-org:(\d+)$/.exec(slotlockLiveTenant(principal.tenantRef))?.[1] : undefined;
854
885
  if (orgId !== undefined && !(await organizationEnabled?.(orgId)))
855
886
  return null;
887
+ if (slotlockLiveTenant(principal.tenantRef).startsWith('team:') && !(await teamActive?.(principal.tenantRef)))
888
+ return null;
856
889
  // Slotlock-Account is a partner key's alone (RFC 0003 P3): with any other credential, or one
857
890
  // that could not act for that account, the request is refused rather than run elsewhere.
858
891
  if (request.headers.has(SLOTLOCK_ACCOUNT_FIELD) && principal.onBehalfOf === undefined)
@@ -929,6 +962,8 @@ export async function startSlotlockServer(config, log) {
929
962
  maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
930
963
  // A granted approval's booking is made as an agent's would be (RFC 0004 A4).
931
964
  agentBackend,
965
+ // Holiday calendars by URL (RFC 0004 B1), never from a private address.
966
+ holidayFetch: createSlotlockGuardedFetch(),
932
967
  partners: createSlotlockPartnerStore(sql, {
933
968
  keys: createSlotlockApiKeyStore(sql),
934
969
  eraseTenant: (tenantRef) => store.eraseTenant({ tenantRef }),
@@ -974,9 +1009,28 @@ export async function startSlotlockServer(config, log) {
974
1009
  ? { organizations: config.dashboard.organizations }
975
1010
  : {}),
976
1011
  ...(config.dashboard.organizations === 'enabled' ? { teams: createSlotlockDashboardTeams(sql) } : {}),
1012
+ ...(config.dashboard.teamWorkspaces ? { teamWorkspaces: true } : {}),
1013
+ ...(config.dashboard.verifiedDomains
1014
+ ? { domains: { resolvers: slotlockDomainResolvers(config.dashboard.verifiedDomains.resolver) } }
1015
+ : {}),
1016
+ // Teams' single sign-on: their Owners' URLs fetched never from a private address.
1017
+ ...(config.dashboard.teamSso
1018
+ ? { sso: { secretBox: createSlotlockSecretBox(config.dashboard.teamSso.secret), fetch: createSlotlockGuardedFetch() } }
1019
+ : {}),
1020
+ ...(config.dashboard.teamScim ? { scim: true } : {}),
1021
+ ...(config.dashboard.invitationEmail
1022
+ ? {
1023
+ invitations: createSlotlockInvitationEmailSender(config.dashboard.invitationEmail, {
1024
+ ...(config.dashboard.name ? { brandName: config.dashboard.name } : {}),
1025
+ }),
1026
+ }
1027
+ : {}),
977
1028
  keys: createSlotlockApiKeyStore(sql),
978
- resources: createSlotlockDashboardResources(store, { agentBackend }),
1029
+ // Holiday calendars by address (RFC 0004 B1), never from a private address.
1030
+ resources: createSlotlockDashboardResources(store, { agentBackend, holidayFetch: createSlotlockGuardedFetch() }),
979
1031
  state: createSlotlockDashboardState(sql),
1032
+ // Roles from the database on every request, and "Sign out everywhere" (workspaces.ts).
1033
+ workspaces: createSlotlockDashboardWorkspaces(sql),
980
1034
  availability: config.availability,
981
1035
  ...(config.usage ? { usage: true } : {}),
982
1036
  ...(config.quotas ? { quotas: true } : {}),
@@ -1037,6 +1091,23 @@ export async function startSlotlockServer(config, log) {
1037
1091
  SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
1038
1092
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
1039
1093
  ...(config.usage ? { usage: true } : {}),
1094
+ // Holiday calendar subscriptions (RFC 0004 B1), never from a private address.
1095
+ holidayFetch: createSlotlockGuardedFetch(),
1096
+ // Team workspaces' verified domains, looked up again each day; each change in the team's audit log.
1097
+ ...(config.verifiedDomains
1098
+ ? {
1099
+ domainChecks: (() => {
1100
+ const domains = createSlotlockWorkspaceDomains(sql);
1101
+ const resolvers = slotlockDomainResolvers(config.verifiedDomains.resolver);
1102
+ const audit = createSlotlockStore(sql, retention);
1103
+ return () => runSlotlockDomainChecks({
1104
+ domains,
1105
+ resolvers,
1106
+ record: (tenantRef, event) => audit.withTenant(tenantRef, (tenant) => tenant.recordAuditEvent({ tenantRef, event })).then(() => undefined),
1107
+ });
1108
+ })(),
1109
+ }
1110
+ : {}),
1040
1111
  // Counts only, never the tenant.
1041
1112
  onPass: (result) => {
1042
1113
  metrics?.maintenance(result);
@@ -1250,8 +1321,243 @@ function hoursLine(resource) {
1250
1321
  external_ref: resource.externalRef,
1251
1322
  timezone: resource.timezone,
1252
1323
  hours: resource.availabilityRules ?? null,
1324
+ inherited: resource.inheritedAvailability ?? null,
1253
1325
  })}\n`;
1254
1326
  }
1327
+ /**
1328
+ * `tenant export [--out <dir>]` (RFC 0004 D3): everything the tenant keeps, read in one snapshot and
1329
+ * stored nowhere: JSON lines on stdout, or one NDJSON file a table in a new or empty directory.
1330
+ * Prints what it wrote, and records it in the audit log.
1331
+ */
1332
+ async function runTenantExport(config, io, out) {
1333
+ if (out !== undefined) {
1334
+ await mkdir(out, { recursive: true });
1335
+ if ((await readdir(out)).length > 0) {
1336
+ io.stderr.write(`slotlock: ${out} is not empty: export into a new or empty directory\n`);
1337
+ return 1;
1338
+ }
1339
+ }
1340
+ const sql = connect(config.databaseUrl, 1);
1341
+ try {
1342
+ const store = createSlotlockStore(sql);
1343
+ const tenantRef = config.tenantRef;
1344
+ const totals = await store.withTenant(tenantRef, async (tenant) => {
1345
+ const read = await tenant.streamTenantExport({
1346
+ tenantRef,
1347
+ onPart: async ({ table, lines }) => {
1348
+ if (out !== undefined) {
1349
+ await appendFile(joinPath(out, `${table}.ndjson`), `${lines.join('\n')}\n`, { mode: 0o600 });
1350
+ }
1351
+ else {
1352
+ io.stdout.write(lines.map((line) => `{"table":${JSON.stringify(table)},"row":${line}}\n`).join(''));
1353
+ }
1354
+ },
1355
+ });
1356
+ await tenant.recordAuditEvent({
1357
+ tenantRef,
1358
+ event: { action: 'export.created', actorType: 'system', actorRef: 'cli', targetType: 'export', metadata: { rows: read.rows } },
1359
+ });
1360
+ return read;
1361
+ });
1362
+ if (out !== undefined)
1363
+ io.stdout.write(`${JSON.stringify({ directory: out, ...totals })}\n`);
1364
+ return 0;
1365
+ }
1366
+ finally {
1367
+ await sql.end({ timeout: 5 });
1368
+ }
1369
+ }
1370
+ /**
1371
+ * `audit verify|export` (RFC 0004 D2): the tenant's workspace audit log. verify exits 1 when the
1372
+ * chain is broken; export prints every event kept, newest first, as JSON lines.
1373
+ */
1374
+ async function runAudit(config, io, subcommand) {
1375
+ const sql = connect(config.databaseUrl, 1);
1376
+ try {
1377
+ const store = createSlotlockStore(sql);
1378
+ const tenantRef = config.tenantRef;
1379
+ if (subcommand === 'audit verify') {
1380
+ const verified = await store.withTenant(tenantRef, (tenant) => tenant.verifyAuditChain({ tenantRef }));
1381
+ io.stdout.write(`${JSON.stringify({ checked: verified.checked, broken_at: verified.brokenAt })}\n`);
1382
+ if (verified.brokenAt === null)
1383
+ return 0;
1384
+ io.stderr.write(`slotlock: the audit log's chain breaks at event ${verified.brokenAt}: it was changed outside Slotlock\n`);
1385
+ return 1;
1386
+ }
1387
+ let before;
1388
+ for (;;) {
1389
+ const page = await store.withTenant(tenantRef, (tenant) => tenant.listAuditEvents({ tenantRef, limit: 200, ...(before ? { beforeId: before } : {}) }));
1390
+ for (const event of page)
1391
+ io.stdout.write(`${JSON.stringify(slotlockAuditEventRecord(event))}\n`);
1392
+ const last = page.at(-1);
1393
+ if (page.length < 200 || !last)
1394
+ return 0;
1395
+ before = last.id;
1396
+ }
1397
+ }
1398
+ finally {
1399
+ await sql.end({ timeout: 5 });
1400
+ }
1401
+ }
1402
+ /**
1403
+ * `subject export|erase` (RFC 0004 D3): what the tenant keeps about one person, and taking them out
1404
+ * of it. The erasure is recorded in the audit log without the person.
1405
+ */
1406
+ async function runSubject(config, io, subcommand, subject) {
1407
+ const sql = connect(config.databaseUrl, 1);
1408
+ try {
1409
+ const store = createSlotlockStore(sql);
1410
+ const tenantRef = config.tenantRef;
1411
+ try {
1412
+ if (subcommand === 'subject export') {
1413
+ const exported = await store.withTenant(tenantRef, (tenant) => tenant.exportSubject({ tenantRef, subject, limit: 10_000 }));
1414
+ for (const [table, rows] of Object.entries(exported.records)) {
1415
+ for (const row of rows)
1416
+ io.stdout.write(`${JSON.stringify({ table, row })}\n`);
1417
+ }
1418
+ if (exported.truncated)
1419
+ io.stderr.write('slotlock: a table had more than 10,000 rows; the first 10,000 are listed\n');
1420
+ return 0;
1421
+ }
1422
+ const { erased } = await store.withTenant(tenantRef, async (tenant) => {
1423
+ const result = await tenant.eraseSubject({ tenantRef, subject });
1424
+ await tenant.recordAuditEvent({
1425
+ tenantRef,
1426
+ event: {
1427
+ action: 'subject.erased',
1428
+ actorType: 'system',
1429
+ actorRef: 'cli',
1430
+ targetType: 'subject',
1431
+ metadata: {
1432
+ by: Object.keys(subject)[0],
1433
+ ...Object.fromEntries(Object.entries(result.erased).filter(([, rows]) => rows > 0)),
1434
+ },
1435
+ },
1436
+ });
1437
+ return result;
1438
+ }, { isolation: 'read committed' });
1439
+ io.stdout.write(`${JSON.stringify({ erased })}\n`);
1440
+ return 0;
1441
+ }
1442
+ catch (error) {
1443
+ if (error?.code === 'invalid_subject') {
1444
+ io.stderr.write('slotlock: give an email address, or a holder, contact or booking reference of 1-500 bytes\n');
1445
+ return 2;
1446
+ }
1447
+ throw error;
1448
+ }
1449
+ }
1450
+ finally {
1451
+ await sql.end({ timeout: 5 });
1452
+ }
1453
+ }
1454
+ /**
1455
+ * `closures list|add|delete|import` (RFC 0004 B1): the tenant's closures and special days. Exit 0, 1
1456
+ * when a name, id or file is not found or refused, as JSON lines on stdout.
1457
+ */
1458
+ async function runClosures(config, io, subcommand, operands, given) {
1459
+ const sql = connect(config.databaseUrl, 1);
1460
+ const fail = (message) => {
1461
+ io.stderr.write(`slotlock: ${message}\n`);
1462
+ return 1;
1463
+ };
1464
+ try {
1465
+ const store = createSlotlockStore(sql);
1466
+ const tenantRef = config.tenantRef;
1467
+ const line = (exception) => io.stdout.write(`${JSON.stringify({
1468
+ id: exception.id,
1469
+ location: exception.location ?? null,
1470
+ resource_id: exception.resourceId ?? null,
1471
+ starts_on: exception.startsOn,
1472
+ ends_on: exception.endsOn,
1473
+ outcome: exception.outcome,
1474
+ hours: exception.hours ?? null,
1475
+ label: exception.label,
1476
+ source: exception.source,
1477
+ })}\n`);
1478
+ let scope = {};
1479
+ if (given.location !== undefined)
1480
+ scope = { location: given.location };
1481
+ if (given.resource !== undefined) {
1482
+ const resources = await tenantResources(store, tenantRef);
1483
+ const named = resources.find((resource) => resource.id === given.resource) ??
1484
+ resources.find((resource) => resource.externalRef === given.resource);
1485
+ if (!named)
1486
+ return fail('no resource with that reference or id in this tenant');
1487
+ scope = { resourceId: named.id };
1488
+ }
1489
+ try {
1490
+ if (subcommand === 'closures list') {
1491
+ const exceptions = await store.withTenant(tenantRef, (tenant) => tenant.listAvailabilityExceptions({
1492
+ tenantRef,
1493
+ from: new Date(Date.now() - 86_400_000).toISOString().slice(0, 10),
1494
+ limit: 1_000,
1495
+ }));
1496
+ for (const exception of exceptions)
1497
+ line(exception);
1498
+ return 0;
1499
+ }
1500
+ if (subcommand === 'closures delete') {
1501
+ const deleted = await store.withTenant(tenantRef, (tenant) => tenant.deleteAvailabilityException({ tenantRef, id: operands[0] }));
1502
+ if (!deleted)
1503
+ return fail('no closure with that id in this tenant');
1504
+ io.stdout.write(`${JSON.stringify({ deleted: true })}\n`);
1505
+ return 0;
1506
+ }
1507
+ if (subcommand === 'closures add') {
1508
+ let hours;
1509
+ if (given.open !== undefined) {
1510
+ const match = /^(\d{2}):(\d{2})-(\d{2}):(\d{2})$/.exec(given.open);
1511
+ if (!match)
1512
+ return fail('--open takes HH:MM-HH:MM, like 10:00-14:00');
1513
+ const [opens, closes] = [Number(match[1]) * 60 + Number(match[2]), Number(match[3]) * 60 + Number(match[4])];
1514
+ hours = [{ startMinutes: opens, durationMinutes: closes > opens ? closes - opens : closes + 1_440 - opens }];
1515
+ }
1516
+ const exception = await store.withTenant(tenantRef, (tenant) => tenant.createAvailabilityException({
1517
+ tenantRef,
1518
+ ...scope,
1519
+ startsOn: operands[0],
1520
+ ...(operands[1] !== undefined ? { endsOn: operands[1] } : {}),
1521
+ outcome: hours ? 'open' : 'closed',
1522
+ ...(hours ? { hours } : {}),
1523
+ label: given.label ?? '',
1524
+ }));
1525
+ line(exception);
1526
+ return 0;
1527
+ }
1528
+ // closures import <file> [--label <calendar name>]
1529
+ let text;
1530
+ try {
1531
+ text = await readFile(operands[0], 'utf8');
1532
+ }
1533
+ catch {
1534
+ return fail('that file cannot be read');
1535
+ }
1536
+ const read = await readSlotlockHolidayCalendar({
1537
+ icalendar: text,
1538
+ ...(given.label !== undefined ? { name: given.label } : {}),
1539
+ });
1540
+ const result = await store.withTenant(tenantRef, (tenant) => tenant.importHolidayCalendar({ tenantRef, ...scope, name: read.name, holidays: read.holidays, span: read.span }));
1541
+ io.stdout.write(`${JSON.stringify({ imported: result.imported, removed: result.removed, skipped: read.skipped, calendar: result.calendar.name })}\n`);
1542
+ return 0;
1543
+ }
1544
+ catch (error) {
1545
+ const code = error.code;
1546
+ if (code === 'invalid_availability_exception' ||
1547
+ code === 'invalid_holiday_calendar' ||
1548
+ code === 'location_not_found' ||
1549
+ code === 'resource_not_found' ||
1550
+ code === 'availability_exceptions_limit_reached' ||
1551
+ code === 'holiday_calendars_limit_reached') {
1552
+ return fail(error.message);
1553
+ }
1554
+ throw error;
1555
+ }
1556
+ }
1557
+ finally {
1558
+ await sql.end({ timeout: 5 });
1559
+ }
1560
+ }
1255
1561
  /** Every resource of the tenant in use (with `includeArchived`, every one), a page at a time. */
1256
1562
  async function tenantResources(store, tenantRef, includeArchived = false) {
1257
1563
  const all = [];
@@ -1273,14 +1579,22 @@ async function tenantResources(store, tenantRef, includeArchived = false) {
1273
1579
  * `hours show|set|clear`: print, set or clear (`rules` null) the hours of the tenant's resource
1274
1580
  * named by reference or id, or of every resource. Returns false when no resource has that name.
1275
1581
  */
1276
- async function runHours(config, out, target, rules) {
1582
+ async function runHours(config, out, target, rules, override = 'none') {
1277
1583
  const sql = connect(config.databaseUrl, 1);
1278
1584
  try {
1279
1585
  const store = createSlotlockStore(sql);
1280
1586
  const tenantRef = config.tenantRef;
1281
1587
  if (target === 'all') {
1282
- const updated = await store.withTenant(tenantRef, (tenant) => tenant.setTenantAvailability({ tenantRef, rules: rules ?? null }));
1283
- out.write(`${JSON.stringify({ updated })}\n`);
1588
+ // The tenant's hours, which every resource without its own inherits (RFC 0004 B2).
1589
+ const result = await store.withTenant(tenantRef, (tenant) => tenant.storeTenantAvailability({ tenantRef, rules: rules ?? null, overrideResources: override }));
1590
+ out.write(`${JSON.stringify({ inheriting: result.inheriting, own_hours: result.ownHours })}\n`);
1591
+ return true;
1592
+ }
1593
+ if (typeof target === 'object' && 'location' in target) {
1594
+ const result = await store.withTenant(tenantRef, (tenant) => tenant.setLocationAvailability({ tenantRef, location: target.location, rules: rules ?? null }));
1595
+ if (!result)
1596
+ return false;
1597
+ out.write(`${JSON.stringify({ location: target.location, inheriting: result.inheriting, own_hours: result.ownHours })}\n`);
1284
1598
  return true;
1285
1599
  }
1286
1600
  const resources = await tenantResources(store, tenantRef);
@@ -1298,6 +1612,8 @@ async function runHours(config, out, target, rules) {
1298
1612
  out.write(hoursLine(named));
1299
1613
  return true;
1300
1614
  }
1615
+ if (!('resource' in target))
1616
+ return false;
1301
1617
  const updated = await store.withTenant(tenantRef, (tenant) => tenant.setResourceAvailability({ tenantRef, id: named.id, rules }));
1302
1618
  if (!updated)
1303
1619
  return false;
@@ -1780,11 +2096,16 @@ async function maintainTenant(config, caps) {
1780
2096
  noShows: 0,
1781
2097
  waitlistOffers: 0,
1782
2098
  approvalsExpired: 0,
2099
+ bookingsCompleted: 0,
2100
+ remindersDue: 0,
2101
+ holidayCalendarsImported: 0,
1783
2102
  capped: true,
1784
2103
  passes: 0,
1785
2104
  };
2105
+ // Holiday calendar subscriptions by URL (RFC 0004 B1), never from a private address.
2106
+ const holidayFetch = createSlotlockGuardedFetch();
1786
2107
  while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
1787
- const pass = await maintainSlotlockTenant(store, config.tenantRef);
2108
+ const pass = await maintainSlotlockTenant(store, config.tenantRef, { holidayFetch });
1788
2109
  sum.passes += 1;
1789
2110
  sum.extended += pass.extended;
1790
2111
  sum.conflicts += pass.conflicts;
@@ -1795,6 +2116,9 @@ async function maintainTenant(config, caps) {
1795
2116
  sum.noShows += pass.noShows;
1796
2117
  sum.waitlistOffers += pass.waitlistOffers;
1797
2118
  sum.approvalsExpired += pass.approvalsExpired;
2119
+ sum.bookingsCompleted += pass.bookingsCompleted;
2120
+ sum.remindersDue += pass.remindersDue;
2121
+ sum.holidayCalendarsImported += pass.holidayCalendarsImported;
1798
2122
  // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
1799
2123
  // before the series it had put back, the next pass meets new ones (and perhaps a healthy
1800
2124
  // one); once it reached them, another pass would meet them again.
@@ -1805,6 +2129,8 @@ async function maintainTenant(config, caps) {
1805
2129
  // Every tenant's expired assistant conversations, a tenant only the dashboard serves
1806
2130
  // included. Counted nowhere: the counts above are this tenant's.
1807
2131
  await store.sweepAssistantConversations();
2132
+ // And finished team invitations, with the addresses they were sent to, and ended sessions.
2133
+ await store.sweepDashboardRecords();
1808
2134
  return sum;
1809
2135
  });
1810
2136
  return total ?? null;
@@ -1872,11 +2198,26 @@ Commands:
1872
2198
  resource delete <ref> Delete a resource and its history; refused while a booking, hold
1873
2199
  or event on it has not ended
1874
2200
  hours show [--resource <ref>]
1875
- Print each resource's bookable hours (null: the server's)
1876
- hours set <json> (--resource <ref> | --all)
1877
- Give one resource, or every one, weekly hours of its own; [] closes
1878
- hours clear (--resource <ref> | --all)
1879
- Hand one resource, or every one, back to the server's hours
2201
+ Print each resource's bookable hours: its own (null when none) and
2202
+ what it inherits from its location or the tenant
2203
+ hours set <json> (--resource <ref> | --location <ref> | --all [--override | --matching])
2204
+ Give one resource, one location or the tenant weekly hours; a
2205
+ resource without its own inherits its location's, then the
2206
+ tenant's, then the server's; --override also clears resources'
2207
+ own hours, --matching only those the same as the tenant's were
2208
+ or as these (copies 0.8.0 made); [] closes
2209
+ hours clear (--resource <ref> | --location <ref> | --all)
2210
+ Clear those hours, so they inherit from further up
2211
+ closures list Print the tenant's closures and special days from yesterday on
2212
+ closures add <first-day> [<last-day>] --label <text> [--open HH:MM-HH:MM]
2213
+ [--resource <ref> | --location <ref>]
2214
+ Close whole days (YYYY-MM-DD), or with --open give them those hours
2215
+ instead, everywhere or for one location or resource
2216
+ closures delete <id> Remove a closure
2217
+ closures import <file> [--resource <ref> | --location <ref>] [--label <calendar name>]
2218
+ Close the days an iCalendar file of holidays names (each all-day
2219
+ event, for the next two years); importing the calendar again (by its
2220
+ name: --label, else its own) updates them and removes those it dropped
1880
2221
  rules set <json> --resource <ref>
1881
2222
  Set what a booking on the resource must satisfy, like
1882
2223
  {"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}
@@ -1946,10 +2287,24 @@ Commands:
1946
2287
  no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
1947
2288
  only the schema owner suspends)
1948
2289
  tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
2290
+ tenant export [--out <dir>]
2291
+ Print everything the tenant keeps, read at one moment, one JSON line a
2292
+ row ({"table": ..., "row": ...}); with --out, one <table>.ndjson file a
2293
+ table in that directory (new or empty). Secrets stay out
1949
2294
  tenant erase --confirm <tenant>
1950
2295
  Delete everything the tenant has, its API keys included, for good;
1951
2296
  --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is
1952
2297
  paid for (cancel the subscription first)
2298
+ audit verify Check the tenant's audit log chain: prints how many events held and
2299
+ the first that does not (exit 1 when one does not)
2300
+ audit export Print the tenant's audit log, newest first, one JSON object per line
2301
+ subject export (--email <address> | --holder <ref> | --contact <ref> | --reference <ref>)
2302
+ Print what the tenant keeps about one person, one JSON line per row:
2303
+ {"table": ..., "row": ...}, up to 10,000 rows a table
2304
+ subject erase (--email <address> | --holder <ref> | --contact <ref> | --reference <ref>)
2305
+ Take one person out of the tenant's data: their contacts, attendee
2306
+ and waitlist entries go; their references become an erased name;
2307
+ bookings stay as time taken. Prints what it changed, by table
1953
2308
  plan status Print the deployment's default plan and the tenant's own (hosted)
1954
2309
  plan default (free | developer | none) [--from <date>]
1955
2310
  Put every tenant without a plan of its own on this one from --from
@@ -2053,10 +2408,26 @@ Environment:
2053
2408
  SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;
2054
2409
  or enabled: each person's own tenant, plus the organizations an
2055
2410
  owner turns on from the dashboard's Teams page
2411
+ SLOTLOCK_DASHBOARD_TEAM_WORKSPACES on: anyone signed in creates team workspaces and invites
2412
+ people to them by email, each with a role (default off; not
2413
+ with organization ids)
2056
2414
  SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
2057
2415
  SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the
2058
2416
  sign-in page (none named without it)
2059
2417
  SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links
2418
+ SLOTLOCK_INVITATION_EMAIL resend or postmark: send team invitations by email (with team
2419
+ workspaces; without it, the person inviting sends the link)
2420
+ SLOTLOCK_INVITATION_EMAIL_KEY Resend's API key, or Postmark's server token
2421
+ SLOTLOCK_INVITATION_EMAIL_FROM The sender the provider verified: team@example.com or Name <...>
2422
+ SLOTLOCK_VERIFIED_DOMAINS on or off (default): team Owners verify their domain with a DNS
2423
+ record, and people at it may ask to join (with team workspaces)
2424
+ SLOTLOCK_DNS_RESOLVER doh (default: Cloudflare, checked by Google) or system: how a
2425
+ verified domain's record is looked up each day
2426
+ SLOTLOCK_TEAM_SSO on or off (default): each team signs in through its own identity
2427
+ provider over OpenID Connect (with verified domains)
2428
+ SLOTLOCK_SSO_SECRET 32+ characters: seals each team's client secret (with team SSO)
2429
+ SLOTLOCK_TEAM_SCIM on or off (default): each team's identity provider provisions its
2430
+ people over SCIM 2.0 at /dashboard/scim/v2 (with verified domains)
2060
2431
  HOST Listen address (default 127.0.0.1)
2061
2432
  PORT Listen port (default 8080)
2062
2433
 
@@ -2096,13 +2467,16 @@ export async function runSlotlockCli(argv, io) {
2096
2467
  const subcommand = command === 'resource' ||
2097
2468
  command === 'key' ||
2098
2469
  command === 'hours' ||
2470
+ command === 'closures' ||
2099
2471
  command === 'rules' ||
2100
2472
  command === 'tenant' ||
2101
2473
  command === 'plan' ||
2102
2474
  command === 'partner' ||
2103
2475
  command === 'overage' ||
2104
2476
  command === 'billing' ||
2105
- command === 'sandbox'
2477
+ command === 'sandbox' ||
2478
+ command === 'audit' ||
2479
+ command === 'subject'
2106
2480
  ? `${command} ${operands[0] ?? ''}`.trim()
2107
2481
  : command;
2108
2482
  const expectedOperands = {
@@ -2117,6 +2491,7 @@ export async function runSlotlockCli(argv, io) {
2117
2491
  'resource restore': 2,
2118
2492
  'resource delete': 2,
2119
2493
  'tenant status': 1,
2494
+ 'tenant export': 1,
2120
2495
  'tenant suspend': 1,
2121
2496
  'tenant unsuspend': 1,
2122
2497
  'tenant erase': 1,
@@ -2134,6 +2509,10 @@ export async function runSlotlockCli(argv, io) {
2134
2509
  'hours show': 1,
2135
2510
  'hours set': 2,
2136
2511
  'hours clear': 1,
2512
+ 'closures list': 1,
2513
+ 'closures add': 2,
2514
+ 'closures delete': 2,
2515
+ 'closures import': 2,
2137
2516
  'rules set': 2,
2138
2517
  'rules clear': 1,
2139
2518
  'partner create': 3,
@@ -2147,8 +2526,13 @@ export async function runSlotlockCli(argv, io) {
2147
2526
  'partner close': 2,
2148
2527
  'overage report': 1,
2149
2528
  'billing daily': 1,
2529
+ 'audit verify': 1,
2530
+ 'audit export': 1,
2531
+ 'subject export': 1,
2532
+ 'subject erase': 1,
2150
2533
  };
2151
- const expected = expectedOperands[subcommand];
2534
+ // closures add takes a last day or not.
2535
+ const expected = subcommand === 'closures add' && operands.length === 3 ? 3 : expectedOperands[subcommand];
2152
2536
  if (expected === undefined)
2153
2537
  return usage(`unknown command "${subcommand}"`);
2154
2538
  if (operands.length !== expected)
@@ -2202,16 +2586,47 @@ export async function runSlotlockCli(argv, io) {
2202
2586
  if (named !== 'none')
2203
2587
  planDefault = { plan: named, appliesFrom };
2204
2588
  }
2589
+ const subjectOptions = ['email', 'holder', 'contact', 'reference'].filter((option) => values[option] !== undefined);
2590
+ if (subjectOptions.length > 0 && !subcommand.startsWith('subject ')) {
2591
+ return usage('--email, --holder, --contact and --reference apply only to subject export and subject erase');
2592
+ }
2593
+ if (subcommand.startsWith('subject ') && subjectOptions.length !== 1) {
2594
+ return usage(`${subcommand} needs exactly one of --email, --holder, --contact or --reference`);
2595
+ }
2596
+ if (values.out !== undefined && subcommand !== 'tenant export')
2597
+ return usage('--out applies only to tenant export');
2205
2598
  if (subcommand === 'tenant erase' && values.confirm === undefined) {
2206
2599
  return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
2207
2600
  }
2208
2601
  if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
2209
2602
  return usage('--all applies only to hours set and hours clear');
2210
2603
  }
2604
+ if (values.override && subcommand !== 'hours set')
2605
+ return usage('--override applies only to hours set --all');
2606
+ if (values.matching && subcommand !== 'hours set')
2607
+ return usage('--matching applies only to hours set --all');
2608
+ if (values.override && values.matching)
2609
+ return usage('--override and --matching: give one');
2211
2610
  if (values.resource !== undefined &&
2212
2611
  !subcommand.startsWith('hours ') &&
2213
- !subcommand.startsWith('rules ')) {
2214
- return usage('--resource applies only to hours and rules commands');
2612
+ !subcommand.startsWith('rules ') &&
2613
+ !subcommand.startsWith('closures ')) {
2614
+ return usage('--resource applies only to hours, closures and rules commands');
2615
+ }
2616
+ if (values.location !== undefined && !subcommand.startsWith('hours ') && !subcommand.startsWith('closures ')) {
2617
+ return usage('--location applies only to hours and closures commands');
2618
+ }
2619
+ if (values.label !== undefined && subcommand !== 'closures add' && subcommand !== 'closures import') {
2620
+ return usage('--label applies only to closures add and closures import');
2621
+ }
2622
+ if (values.open !== undefined && subcommand !== 'closures add') {
2623
+ return usage('--open applies only to closures add');
2624
+ }
2625
+ if (subcommand.startsWith('closures ') && values.resource !== undefined && values.location !== undefined) {
2626
+ return usage(`${subcommand} takes --resource or --location, not both`);
2627
+ }
2628
+ if (subcommand === 'closures add' && (operands.length < 2 || operands.length > 3 || values.label === undefined)) {
2629
+ return usage('closures add needs <first-day> [<last-day>] --label <text>');
2215
2630
  }
2216
2631
  if (subcommand.startsWith('rules ') && values.resource === undefined) {
2217
2632
  return usage(`${subcommand} needs --resource <ref>`);
@@ -2228,9 +2643,13 @@ export async function runSlotlockCli(argv, io) {
2228
2643
  }
2229
2644
  }
2230
2645
  if ((subcommand === 'hours set' || subcommand === 'hours clear') &&
2231
- (values.resource === undefined) === (values.all !== true)) {
2232
- return usage(`${subcommand} needs --resource <ref> or --all, one of them`);
2646
+ [values.resource !== undefined, values.location !== undefined, values.all === true].filter(Boolean).length !== 1) {
2647
+ return usage(`${subcommand} needs --resource <ref>, --location <ref> or --all, one of them`);
2233
2648
  }
2649
+ if (values.override && values.all !== true)
2650
+ return usage('--override applies only to hours set --all');
2651
+ if (values.matching && values.all !== true)
2652
+ return usage('--matching applies only to hours set --all');
2234
2653
  let hours = null;
2235
2654
  if (subcommand === 'hours set') {
2236
2655
  let candidate;
@@ -2407,6 +2826,8 @@ export async function runSlotlockCli(argv, io) {
2407
2826
  }
2408
2827
  return 0;
2409
2828
  }
2829
+ case 'tenant export':
2830
+ return runTenantExport(readSlotlockDatabaseConfig(io.env), io, values.out);
2410
2831
  case 'tenant status':
2411
2832
  case 'tenant suspend':
2412
2833
  case 'tenant unsuspend':
@@ -2484,16 +2905,40 @@ export async function runSlotlockCli(argv, io) {
2484
2905
  case 'hours clear': {
2485
2906
  const target = values.all === true
2486
2907
  ? 'all'
2487
- : values.resource !== undefined
2488
- ? { resource: values.resource }
2489
- : 'show';
2490
- const found = await runHours(readSlotlockDatabaseConfig(io.env), io.stdout, target, subcommand === 'hours show' ? undefined : hours);
2908
+ : values.location !== undefined
2909
+ ? { location: values.location }
2910
+ : values.resource !== undefined
2911
+ ? { resource: values.resource }
2912
+ : 'show';
2913
+ const found = await runHours(readSlotlockDatabaseConfig(io.env), io.stdout, target, subcommand === 'hours show' ? undefined : hours, values.override === true ? 'all' : values.matching === true ? 'matching' : 'none');
2491
2914
  if (!found) {
2492
- io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
2915
+ io.stderr.write(values.location !== undefined
2916
+ ? 'slotlock: no location with that reference in this tenant\n'
2917
+ : 'slotlock: no resource with that reference or id in this tenant\n');
2493
2918
  return 1;
2494
2919
  }
2495
2920
  return 0;
2496
2921
  }
2922
+ case 'audit verify':
2923
+ case 'audit export':
2924
+ return runAudit(readSlotlockDatabaseConfig(io.env), io, subcommand);
2925
+ case 'subject export':
2926
+ case 'subject erase': {
2927
+ const option = subjectOptions[0];
2928
+ return runSubject(readSlotlockDatabaseConfig(io.env), io, subcommand, {
2929
+ [option]: values[option],
2930
+ });
2931
+ }
2932
+ case 'closures list':
2933
+ case 'closures add':
2934
+ case 'closures delete':
2935
+ case 'closures import':
2936
+ return runClosures(readSlotlockDatabaseConfig(io.env), io, subcommand, operands.slice(1), {
2937
+ ...(values.resource !== undefined ? { resource: values.resource } : {}),
2938
+ ...(values.location !== undefined ? { location: values.location } : {}),
2939
+ ...(values.label !== undefined ? { label: values.label } : {}),
2940
+ ...(values.open !== undefined ? { open: values.open } : {}),
2941
+ });
2497
2942
  case 'rules set':
2498
2943
  case 'rules clear': {
2499
2944
  const found = await runRules(readSlotlockDatabaseConfig(io.env), io.stdout, values.resource, bookingRules);
@@ -2594,9 +3039,19 @@ function parseCliArguments(argv) {
2594
3039
  month: { type: 'string' },
2595
3040
  event: { type: 'string' },
2596
3041
  resource: { type: 'string' },
3042
+ location: { type: 'string' },
2597
3043
  all: { type: 'boolean' },
3044
+ override: { type: 'boolean' },
3045
+ matching: { type: 'boolean' },
3046
+ label: { type: 'string' },
3047
+ open: { type: 'string' },
2598
3048
  reason: { type: 'string' },
2599
3049
  confirm: { type: 'string' },
3050
+ out: { type: 'string' },
3051
+ email: { type: 'string' },
3052
+ holder: { type: 'string' },
3053
+ contact: { type: 'string' },
3054
+ reference: { type: 'string' },
2600
3055
  from: { type: 'string' },
2601
3056
  help: { type: 'boolean', short: 'h' },
2602
3057
  version: { type: 'boolean', short: 'v' },