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.
Files changed (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. 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
- * `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
615
- * 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.
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' },
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.9.0",
6
+ "version": "0.10.0",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",