slotlock 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/self-host.js
CHANGED
|
@@ -12,15 +12,21 @@ 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';
|
|
24
30
|
import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
25
31
|
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_QUOTA_FUNCTIONS, SLOTLOCK_TENANT_TABLES, SLOTLOCK_USAGE_FUNCTIONS, } from './ddl.js';
|
|
26
32
|
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
@@ -32,7 +38,7 @@ import { isSlotlockResourceScope } from './resource-profile.js';
|
|
|
32
38
|
import { createSlotlockQuotas, noteSlotlockQuotaThreshold, readSlotlockQuotaUse } from './quotas.js';
|
|
33
39
|
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
34
40
|
import { createSlotlockUsageMeter, recordSlotlockUsage } from './usage.js';
|
|
35
|
-
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
41
|
+
import { assertSlotlockSchemaCurrent, createSlotlockStore, slotlockAuditEventRecord, } from './store.js';
|
|
36
42
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
37
43
|
export { SlotlockConfigError };
|
|
38
44
|
/**
|
|
@@ -76,9 +82,18 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
76
82
|
'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
|
|
77
83
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
78
84
|
'SLOTLOCK_DASHBOARD_ORGS',
|
|
85
|
+
'SLOTLOCK_DASHBOARD_TEAM_WORKSPACES',
|
|
79
86
|
'SLOTLOCK_DASHBOARD_NAME',
|
|
80
87
|
'SLOTLOCK_DASHBOARD_TERMS_URL',
|
|
81
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',
|
|
82
97
|
'HOST',
|
|
83
98
|
'PORT',
|
|
84
99
|
]);
|
|
@@ -358,6 +373,11 @@ export function readSlotlockServeConfig(env) {
|
|
|
358
373
|
const organizations = readDashboardOrganizations(env);
|
|
359
374
|
if (organizations !== undefined)
|
|
360
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;
|
|
361
381
|
const allowedUsers = dashboard?.allowedUsers ?? readDashboardUsers(env);
|
|
362
382
|
if (allowedUsers !== undefined)
|
|
363
383
|
config.allowedUsers = allowedUsers;
|
|
@@ -610,13 +630,19 @@ function serveMetrics(target, metrics, path, token) {
|
|
|
610
630
|
}
|
|
611
631
|
/**
|
|
612
632
|
* Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
|
|
613
|
-
* `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
|
|
614
|
-
*
|
|
615
|
-
* 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.
|
|
616
637
|
*/
|
|
617
638
|
export function slotlockServeAllowsTenant(config, tenantRef) {
|
|
618
639
|
// A test environment (RFC 0003 G9) is let through exactly as its live tenant.
|
|
619
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;
|
|
620
646
|
const orgId = /^github-org:(\d+)$/.exec(live)?.[1];
|
|
621
647
|
// With `enabled`, the database decides, as serve's authenticate asks (createSlotlockOrganizationCheck).
|
|
622
648
|
if (orgId !== undefined)
|
|
@@ -821,6 +847,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
821
847
|
: undefined;
|
|
822
848
|
// Teams on Cloud: an organization's keys work while an owner has it turned on.
|
|
823
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;
|
|
824
852
|
const limits = createSlotlockRequestLimits({
|
|
825
853
|
limits: config.limits,
|
|
826
854
|
...(config.sharedLimits
|
|
@@ -856,6 +884,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
856
884
|
const orgId = organizationEnabled ? /^github-org:(\d+)$/.exec(slotlockLiveTenant(principal.tenantRef))?.[1] : undefined;
|
|
857
885
|
if (orgId !== undefined && !(await organizationEnabled?.(orgId)))
|
|
858
886
|
return null;
|
|
887
|
+
if (slotlockLiveTenant(principal.tenantRef).startsWith('team:') && !(await teamActive?.(principal.tenantRef)))
|
|
888
|
+
return null;
|
|
859
889
|
// Slotlock-Account is a partner key's alone (RFC 0003 P3): with any other credential, or one
|
|
860
890
|
// that could not act for that account, the request is refused rather than run elsewhere.
|
|
861
891
|
if (request.headers.has(SLOTLOCK_ACCOUNT_FIELD) && principal.onBehalfOf === undefined)
|
|
@@ -979,10 +1009,28 @@ export async function startSlotlockServer(config, log) {
|
|
|
979
1009
|
? { organizations: config.dashboard.organizations }
|
|
980
1010
|
: {}),
|
|
981
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
|
+
: {}),
|
|
982
1028
|
keys: createSlotlockApiKeyStore(sql),
|
|
983
1029
|
// Holiday calendars by address (RFC 0004 B1), never from a private address.
|
|
984
1030
|
resources: createSlotlockDashboardResources(store, { agentBackend, holidayFetch: createSlotlockGuardedFetch() }),
|
|
985
1031
|
state: createSlotlockDashboardState(sql),
|
|
1032
|
+
// Roles from the database on every request, and "Sign out everywhere" (workspaces.ts).
|
|
1033
|
+
workspaces: createSlotlockDashboardWorkspaces(sql),
|
|
986
1034
|
availability: config.availability,
|
|
987
1035
|
...(config.usage ? { usage: true } : {}),
|
|
988
1036
|
...(config.quotas ? { quotas: true } : {}),
|
|
@@ -1045,6 +1093,21 @@ export async function startSlotlockServer(config, log) {
|
|
|
1045
1093
|
...(config.usage ? { usage: true } : {}),
|
|
1046
1094
|
// Holiday calendar subscriptions (RFC 0004 B1), never from a private address.
|
|
1047
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
|
+
: {}),
|
|
1048
1111
|
// Counts only, never the tenant.
|
|
1049
1112
|
onPass: (result) => {
|
|
1050
1113
|
metrics?.maintenance(result);
|
|
@@ -1261,6 +1324,133 @@ function hoursLine(resource) {
|
|
|
1261
1324
|
inherited: resource.inheritedAvailability ?? null,
|
|
1262
1325
|
})}\n`;
|
|
1263
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
|
+
}
|
|
1264
1454
|
/**
|
|
1265
1455
|
* `closures list|add|delete|import` (RFC 0004 B1): the tenant's closures and special days. Exit 0, 1
|
|
1266
1456
|
* when a name, id or file is not found or refused, as JSON lines on stdout.
|
|
@@ -1939,6 +2129,8 @@ async function maintainTenant(config, caps) {
|
|
|
1939
2129
|
// Every tenant's expired assistant conversations, a tenant only the dashboard serves
|
|
1940
2130
|
// included. Counted nowhere: the counts above are this tenant's.
|
|
1941
2131
|
await store.sweepAssistantConversations();
|
|
2132
|
+
// And finished team invitations, with the addresses they were sent to, and ended sessions.
|
|
2133
|
+
await store.sweepDashboardRecords();
|
|
1942
2134
|
return sum;
|
|
1943
2135
|
});
|
|
1944
2136
|
return total ?? null;
|
|
@@ -2095,10 +2287,24 @@ Commands:
|
|
|
2095
2287
|
no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
|
|
2096
2288
|
only the schema owner suspends)
|
|
2097
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
|
|
2098
2294
|
tenant erase --confirm <tenant>
|
|
2099
2295
|
Delete everything the tenant has, its API keys included, for good;
|
|
2100
2296
|
--confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is
|
|
2101
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
|
|
2102
2308
|
plan status Print the deployment's default plan and the tenant's own (hosted)
|
|
2103
2309
|
plan default (free | developer | none) [--from <date>]
|
|
2104
2310
|
Put every tenant without a plan of its own on this one from --from
|
|
@@ -2202,10 +2408,26 @@ Environment:
|
|
|
2202
2408
|
SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;
|
|
2203
2409
|
or enabled: each person's own tenant, plus the organizations an
|
|
2204
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)
|
|
2205
2414
|
SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
|
|
2206
2415
|
SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the
|
|
2207
2416
|
sign-in page (none named without it)
|
|
2208
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)
|
|
2209
2431
|
HOST Listen address (default 127.0.0.1)
|
|
2210
2432
|
PORT Listen port (default 8080)
|
|
2211
2433
|
|
|
@@ -2252,7 +2474,9 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2252
2474
|
command === 'partner' ||
|
|
2253
2475
|
command === 'overage' ||
|
|
2254
2476
|
command === 'billing' ||
|
|
2255
|
-
command === 'sandbox'
|
|
2477
|
+
command === 'sandbox' ||
|
|
2478
|
+
command === 'audit' ||
|
|
2479
|
+
command === 'subject'
|
|
2256
2480
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
2257
2481
|
: command;
|
|
2258
2482
|
const expectedOperands = {
|
|
@@ -2267,6 +2491,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2267
2491
|
'resource restore': 2,
|
|
2268
2492
|
'resource delete': 2,
|
|
2269
2493
|
'tenant status': 1,
|
|
2494
|
+
'tenant export': 1,
|
|
2270
2495
|
'tenant suspend': 1,
|
|
2271
2496
|
'tenant unsuspend': 1,
|
|
2272
2497
|
'tenant erase': 1,
|
|
@@ -2301,6 +2526,10 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2301
2526
|
'partner close': 2,
|
|
2302
2527
|
'overage report': 1,
|
|
2303
2528
|
'billing daily': 1,
|
|
2529
|
+
'audit verify': 1,
|
|
2530
|
+
'audit export': 1,
|
|
2531
|
+
'subject export': 1,
|
|
2532
|
+
'subject erase': 1,
|
|
2304
2533
|
};
|
|
2305
2534
|
// closures add takes a last day or not.
|
|
2306
2535
|
const expected = subcommand === 'closures add' && operands.length === 3 ? 3 : expectedOperands[subcommand];
|
|
@@ -2357,6 +2586,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2357
2586
|
if (named !== 'none')
|
|
2358
2587
|
planDefault = { plan: named, appliesFrom };
|
|
2359
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');
|
|
2360
2598
|
if (subcommand === 'tenant erase' && values.confirm === undefined) {
|
|
2361
2599
|
return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
|
|
2362
2600
|
}
|
|
@@ -2588,6 +2826,8 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2588
2826
|
}
|
|
2589
2827
|
return 0;
|
|
2590
2828
|
}
|
|
2829
|
+
case 'tenant export':
|
|
2830
|
+
return runTenantExport(readSlotlockDatabaseConfig(io.env), io, values.out);
|
|
2591
2831
|
case 'tenant status':
|
|
2592
2832
|
case 'tenant suspend':
|
|
2593
2833
|
case 'tenant unsuspend':
|
|
@@ -2679,6 +2919,16 @@ export async function runSlotlockCli(argv, io) {
|
|
|
2679
2919
|
}
|
|
2680
2920
|
return 0;
|
|
2681
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
|
+
}
|
|
2682
2932
|
case 'closures list':
|
|
2683
2933
|
case 'closures add':
|
|
2684
2934
|
case 'closures delete':
|
|
@@ -2797,6 +3047,11 @@ function parseCliArguments(argv) {
|
|
|
2797
3047
|
open: { type: 'string' },
|
|
2798
3048
|
reason: { type: 'string' },
|
|
2799
3049
|
confirm: { type: 'string' },
|
|
3050
|
+
out: { type: 'string' },
|
|
3051
|
+
email: { type: 'string' },
|
|
3052
|
+
holder: { type: 'string' },
|
|
3053
|
+
contact: { type: 'string' },
|
|
3054
|
+
reference: { type: 'string' },
|
|
2800
3055
|
from: { type: 'string' },
|
|
2801
3056
|
help: { type: 'boolean', short: 'h' },
|
|
2802
3057
|
version: { type: 'boolean', short: 'v' },
|
package/slotlock.manifest.json
CHANGED