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.
- package/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- 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 +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- 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 +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- 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/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
|
-
*
|
|
612
|
-
* SLOTLOCK_DASHBOARD_USERS is
|
|
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
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
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
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2488
|
-
? {
|
|
2489
|
-
:
|
|
2490
|
-
|
|
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(
|
|
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' },
|