slotlock 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/self-host.js
CHANGED
|
@@ -4,26 +4,41 @@
|
|
|
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
6
|
import { createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
|
|
7
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
import postgres from 'postgres';
|
|
10
10
|
import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
|
|
11
11
|
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, } from './agent-store-backend.js';
|
|
12
|
-
import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
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
|
+
import { SLOTLOCK_SANDBOX_MAX_RACERS, SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
13
14
|
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, } from './dashboard.js';
|
|
14
|
-
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
|
|
15
|
+
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readDashboardUsers, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, readTrustedProxySecret, slotlockKeySubscriptions, } from './config.js';
|
|
15
16
|
import { parseBookingRules } from './booking-rules.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { slotlockApplicationOperation, slotlockApplicationScope, } from './application-api.js';
|
|
18
|
+
import { SLOTLOCK_ACCOUNT_FIELD, createSlotlockPartnerKey, createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, listSlotlockPartners, putSlotlockPartner, revokeSlotlockPartnerKey, setSlotlockPartnerBilling, setSlotlockPartnerStatus, slotlockPartnerContractJson, } from './partners.js';
|
|
19
|
+
import { closeSlotlockPartnerMonth, slotlockClientLabel, slotlockPreviousMonth } from './partner-billing.js';
|
|
20
|
+
import { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
|
|
21
|
+
import { SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT, reportSlotlockOverage, slotlockMonthOf } from './overage.js';
|
|
22
|
+
import { createSlotlockOtlpExporter, createSlotlockOtlpMetricsExporter, readSlotlockOtlpConfig, } from './otel.js';
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_QUOTA_FUNCTIONS, SLOTLOCK_TENANT_TABLES, SLOTLOCK_USAGE_FUNCTIONS, } from './ddl.js';
|
|
18
25
|
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
19
26
|
import { createSlotlockNodeServer, } from './node-server.js';
|
|
20
27
|
import { createSlotlockMetrics } from './metrics.js';
|
|
21
28
|
import { slotlockPackageVersion } from './package-version.js';
|
|
29
|
+
import { readSlotlockPlanDefault, readSlotlockTenantPlan, readSlotlockTenantQuotas, setSlotlockPlanDefault, } from './plans.js';
|
|
30
|
+
import { isSlotlockResourceScope } from './resource-profile.js';
|
|
31
|
+
import { createSlotlockQuotas, noteSlotlockQuotaThreshold, readSlotlockQuotaUse } from './quotas.js';
|
|
22
32
|
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
33
|
+
import { createSlotlockUsageMeter, recordSlotlockUsage } from './usage.js';
|
|
23
34
|
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
24
35
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
25
36
|
export { SlotlockConfigError };
|
|
26
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Every variable the commands read. `slotlock --help`, `.env.example`, `server.json` and the docs
|
|
39
|
+
* (site/docs-src/self-host.md) document these. Not STRIPE_SECRET_KEY, which only the owner's
|
|
40
|
+
* `partner close` reads, to bill partners.
|
|
41
|
+
*/
|
|
27
42
|
export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
28
43
|
'DATABASE_URL',
|
|
29
44
|
'SLOTLOCK_MIGRATE_DATABASE_URL',
|
|
@@ -38,16 +53,31 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
38
53
|
'SLOTLOCK_RETENTION_DAYS',
|
|
39
54
|
'SLOTLOCK_WEBHOOKS',
|
|
40
55
|
'SLOTLOCK_SHARED_LIMITS',
|
|
56
|
+
'SLOTLOCK_USAGE',
|
|
57
|
+
'SLOTLOCK_QUOTAS',
|
|
58
|
+
'SLOTLOCK_OVERAGE',
|
|
41
59
|
'SLOTLOCK_LIMITS',
|
|
42
60
|
'SLOTLOCK_TRUSTED_PROXY_HEADER',
|
|
61
|
+
'SLOTLOCK_TRUSTED_PROXY_SECRET',
|
|
62
|
+
'SLOTLOCK_KNOWN_CLIENTS',
|
|
63
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
64
|
+
'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
|
|
65
|
+
'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT',
|
|
66
|
+
'OTEL_EXPORTER_OTLP_HEADERS',
|
|
67
|
+
'OTEL_SERVICE_NAME',
|
|
43
68
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
44
69
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
70
|
+
'SLOTLOCK_GOOGLE_CLIENT_ID',
|
|
71
|
+
'SLOTLOCK_GOOGLE_CLIENT_SECRET',
|
|
45
72
|
'SLOTLOCK_SESSION_SECRET',
|
|
46
73
|
'SLOTLOCK_ASSISTANT_SECRET',
|
|
74
|
+
'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS',
|
|
47
75
|
'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
|
|
48
76
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
49
77
|
'SLOTLOCK_DASHBOARD_ORGS',
|
|
50
78
|
'SLOTLOCK_DASHBOARD_NAME',
|
|
79
|
+
'SLOTLOCK_DASHBOARD_TERMS_URL',
|
|
80
|
+
'SLOTLOCK_DASHBOARD_PRIVACY_URL',
|
|
51
81
|
'HOST',
|
|
52
82
|
'PORT',
|
|
53
83
|
]);
|
|
@@ -64,7 +94,7 @@ const DAY_MS = 86_400_000;
|
|
|
64
94
|
* every GitHub user gives each a tenant, so the cap keeps one from filling the database.
|
|
65
95
|
*/
|
|
66
96
|
const SERVE_MAX_TENANT_RESOURCES = 10_000;
|
|
67
|
-
/** How often `serve` maintains each tenant
|
|
97
|
+
/** How often `serve` maintains each tenant with data (docs, Operations: at least daily). */
|
|
68
98
|
const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
69
99
|
/** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
|
|
70
100
|
const MAINTAIN_MAX_PASSES = 100;
|
|
@@ -94,6 +124,9 @@ const SERVED_OPERATIONS = new Set([
|
|
|
94
124
|
'slotlock_list_events',
|
|
95
125
|
'slotlock_update_event',
|
|
96
126
|
'slotlock_delete_event',
|
|
127
|
+
'slotlock_confirm_slot',
|
|
128
|
+
'slotlock_reschedule',
|
|
129
|
+
'slotlock_cancel',
|
|
97
130
|
]);
|
|
98
131
|
const HEALTH_QUERY_TIMEOUT_MS = 2_000;
|
|
99
132
|
/** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
|
|
@@ -202,6 +235,39 @@ function readSharedLimits(env) {
|
|
|
202
235
|
}
|
|
203
236
|
return value === 'on';
|
|
204
237
|
}
|
|
238
|
+
/** SLOTLOCK_USAGE: on to meter each tenant's use (usage.ts); off, the default, records nothing. */
|
|
239
|
+
function readUsageMetering(env) {
|
|
240
|
+
const value = optional(env, 'SLOTLOCK_USAGE') ?? 'off';
|
|
241
|
+
if (value !== 'on' && value !== 'off') {
|
|
242
|
+
throw new SlotlockConfigError('SLOTLOCK_USAGE must be on or off');
|
|
243
|
+
}
|
|
244
|
+
return value === 'on';
|
|
245
|
+
}
|
|
246
|
+
/** SLOTLOCK_QUOTAS: on to hold each tenant on a plan to its monthly quotas; it counts with usage. */
|
|
247
|
+
function readQuotas(env, usage) {
|
|
248
|
+
const value = optional(env, 'SLOTLOCK_QUOTAS') ?? 'off';
|
|
249
|
+
if (value !== 'on' && value !== 'off') {
|
|
250
|
+
throw new SlotlockConfigError('SLOTLOCK_QUOTAS must be on or off');
|
|
251
|
+
}
|
|
252
|
+
if (value === 'on' && !usage) {
|
|
253
|
+
throw new SlotlockConfigError('SLOTLOCK_QUOTAS=on needs SLOTLOCK_USAGE=on: a quota is held against the usage it counts');
|
|
254
|
+
}
|
|
255
|
+
return value === 'on';
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* SLOTLOCK_KNOWN_CLIENTS: the integrators metrics name (comma-separated, lower-case names like
|
|
259
|
+
* stackapi or n8n), at most 50, so a scrape stays bounded and names only those the deployment chose.
|
|
260
|
+
*/
|
|
261
|
+
function readKnownClients(env) {
|
|
262
|
+
const value = optional(env, 'SLOTLOCK_KNOWN_CLIENTS');
|
|
263
|
+
if (value === undefined)
|
|
264
|
+
return [];
|
|
265
|
+
const names = value.split(',').map((name) => name.trim());
|
|
266
|
+
if (names.length > 50 || names.some((name) => !/^[a-z][a-z0-9_.-]{0,39}$/.test(name) || name === 'other' || name === 'none')) {
|
|
267
|
+
throw new SlotlockConfigError('SLOTLOCK_KNOWN_CLIENTS must be up to 50 comma-separated lower-case names (letters, digits, _ . -), not other or none');
|
|
268
|
+
}
|
|
269
|
+
return [...new Set(names)];
|
|
270
|
+
}
|
|
205
271
|
function readRestApi(env) {
|
|
206
272
|
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
207
273
|
if (value !== 'on' && value !== 'off') {
|
|
@@ -233,11 +299,39 @@ export function readSlotlockServeConfig(env) {
|
|
|
233
299
|
restApi: readRestApi(env),
|
|
234
300
|
webhooks: readWebhooks(env),
|
|
235
301
|
sharedLimits: readSharedLimits(env),
|
|
302
|
+
usage: readUsageMetering(env),
|
|
303
|
+
quotas: false,
|
|
236
304
|
limits: readLimits(env),
|
|
237
305
|
};
|
|
306
|
+
config.quotas = readQuotas(env, config.usage);
|
|
307
|
+
const overage = optional(env, 'SLOTLOCK_OVERAGE') ?? 'off';
|
|
308
|
+
if (overage !== 'off') {
|
|
309
|
+
const percent = overage === 'on' ? SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT : Number(overage);
|
|
310
|
+
if (!Number.isSafeInteger(percent) || percent < 111 || percent > 1_000) {
|
|
311
|
+
throw new SlotlockConfigError('SLOTLOCK_OVERAGE must be off, on, or the percent of its quota a paying Developer tenant is refused at (111-1000)');
|
|
312
|
+
}
|
|
313
|
+
if (!config.quotas)
|
|
314
|
+
throw new SlotlockConfigError('SLOTLOCK_OVERAGE needs SLOTLOCK_QUOTAS=on: overage is what is used past a quota');
|
|
315
|
+
config.overageStopPercent = percent;
|
|
316
|
+
}
|
|
238
317
|
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
239
318
|
if (trustedProxyHeader !== undefined)
|
|
240
319
|
config.trustedProxyHeader = trustedProxyHeader;
|
|
320
|
+
const trustedProxySecret = readTrustedProxySecret(env, trustedProxyHeader);
|
|
321
|
+
if (trustedProxySecret !== undefined)
|
|
322
|
+
config.trustedProxySecret = trustedProxySecret;
|
|
323
|
+
const knownClients = readKnownClients(env);
|
|
324
|
+
if (knownClients.length > 0)
|
|
325
|
+
config.knownClients = knownClients;
|
|
326
|
+
let otlp;
|
|
327
|
+
try {
|
|
328
|
+
otlp = readSlotlockOtlpConfig(env);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
throw new SlotlockConfigError(error.message);
|
|
332
|
+
}
|
|
333
|
+
if (otlp !== undefined)
|
|
334
|
+
config.otlp = otlp;
|
|
241
335
|
if (authToken !== undefined)
|
|
242
336
|
config.authToken = authToken;
|
|
243
337
|
const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
|
|
@@ -248,6 +342,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
248
342
|
env.SLOTLOCK_SESSION_SECRET?.trim(),
|
|
249
343
|
env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
|
|
250
344
|
env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
|
|
345
|
+
...(env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.split(',').map((part) => part.trim()) ?? []),
|
|
251
346
|
];
|
|
252
347
|
if (others.includes(metricsToken)) {
|
|
253
348
|
throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
|
|
@@ -260,6 +355,9 @@ export function readSlotlockServeConfig(env) {
|
|
|
260
355
|
const organizations = readDashboardOrganizations(env);
|
|
261
356
|
if (organizations !== undefined)
|
|
262
357
|
config.organizations = organizations;
|
|
358
|
+
const allowedUsers = dashboard?.allowedUsers ?? readDashboardUsers(env);
|
|
359
|
+
if (allowedUsers !== undefined)
|
|
360
|
+
config.allowedUsers = allowedUsers;
|
|
263
361
|
if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
|
|
264
362
|
config.confirmationSecret = confirmationSecret;
|
|
265
363
|
}
|
|
@@ -313,11 +411,20 @@ function secretsIn(env) {
|
|
|
313
411
|
'SLOTLOCK_SESSION_SECRET',
|
|
314
412
|
'SLOTLOCK_ASSISTANT_SECRET',
|
|
315
413
|
'SLOTLOCK_METRICS_TOKEN',
|
|
414
|
+
'SLOTLOCK_TRUSTED_PROXY_SECRET',
|
|
316
415
|
]) {
|
|
317
416
|
const value = env[name]?.trim();
|
|
318
417
|
if (value)
|
|
319
418
|
secrets.push(value);
|
|
320
419
|
}
|
|
420
|
+
// A list: each secret in it, as well as the whole.
|
|
421
|
+
const previous = env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.trim();
|
|
422
|
+
if (previous) {
|
|
423
|
+
secrets.push(previous);
|
|
424
|
+
for (const part of previous.split(','))
|
|
425
|
+
if (part.trim())
|
|
426
|
+
secrets.push(part.trim());
|
|
427
|
+
}
|
|
321
428
|
for (const name of ['DATABASE_URL', 'SLOTLOCK_MIGRATE_DATABASE_URL']) {
|
|
322
429
|
const value = env[name]?.trim();
|
|
323
430
|
if (!value)
|
|
@@ -499,19 +606,25 @@ function serveMetrics(target, metrics, path, token) {
|
|
|
499
606
|
};
|
|
500
607
|
}
|
|
501
608
|
/**
|
|
502
|
-
* Whether the dashboard's allowlists let a tenant act
|
|
503
|
-
* SLOTLOCK_DASHBOARD_ORGS lists that organization
|
|
504
|
-
* tenant
|
|
505
|
-
*
|
|
609
|
+
* Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
|
|
610
|
+
* `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
|
|
611
|
+
* `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
|
|
612
|
+
* SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
|
|
506
613
|
*/
|
|
507
|
-
function
|
|
508
|
-
|
|
614
|
+
export function slotlockServeAllowsTenant(config, tenantRef) {
|
|
615
|
+
// A test environment (RFC 0003 G9) is let through exactly as its live tenant.
|
|
616
|
+
const live = slotlockLiveTenant(tenantRef);
|
|
617
|
+
const orgId = /^github-org:(\d+)$/.exec(live)?.[1];
|
|
618
|
+
// With `enabled`, the database decides, as serve's authenticate asks (createSlotlockOrganizationCheck).
|
|
509
619
|
if (orgId !== undefined)
|
|
510
|
-
return config.organizations?.includes(orgId) === true;
|
|
511
|
-
const users = config.
|
|
620
|
+
return config.organizations === 'enabled' || config.organizations?.includes(orgId) === true;
|
|
621
|
+
const users = config.allowedUsers;
|
|
512
622
|
if (users === undefined || users === '*')
|
|
513
623
|
return true;
|
|
514
|
-
|
|
624
|
+
// A Google person (google:<subject>) only while the list names them so.
|
|
625
|
+
if (live.startsWith('google:'))
|
|
626
|
+
return users.includes(live);
|
|
627
|
+
const githubId = /^github:(\d+)$/.exec(live)?.[1];
|
|
515
628
|
return githubId === undefined || users.includes(githubId);
|
|
516
629
|
}
|
|
517
630
|
function serveTenantLimits(caps) {
|
|
@@ -550,6 +663,16 @@ export function createSlotlockMaintenanceStore(sql, caps, options = {}) {
|
|
|
550
663
|
maxHeldHoursPerHolder: caps.holder_held_hours,
|
|
551
664
|
});
|
|
552
665
|
}
|
|
666
|
+
/** Every tenant a paged SECURITY DEFINER list (1,000 names a page, after the last) returns. */
|
|
667
|
+
async function pageTenants(page) {
|
|
668
|
+
const tenants = [];
|
|
669
|
+
for (;;) {
|
|
670
|
+
const rows = await page(tenants.at(-1) ?? null);
|
|
671
|
+
tenants.push(...rows.map(({ tenant }) => tenant));
|
|
672
|
+
if (rows.length < 1_000)
|
|
673
|
+
return tenants;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
553
676
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
554
677
|
export async function startSlotlockServer(config, log) {
|
|
555
678
|
const sql = connect(config.databaseUrl, POOL_SIZE);
|
|
@@ -621,10 +744,41 @@ export async function startSlotlockServer(config, log) {
|
|
|
621
744
|
throw Object.assign(new Error('SLOTLOCK_CONFIRM_WRITES records each accepted confirmation, which this schema cannot: run `slotlock migrate` (or `slotlock serve --migrate`) with this release'), { code: 'schema_outdated' });
|
|
622
745
|
}
|
|
623
746
|
}
|
|
747
|
+
if (config.usage) {
|
|
748
|
+
for (const signature of [...SLOTLOCK_USAGE_FUNCTIONS, ...(config.quotas ? SLOTLOCK_QUOTA_FUNCTIONS : [])]) {
|
|
749
|
+
const [granted] = await sql `
|
|
750
|
+
SELECT CASE WHEN to_regprocedure(${signature}) IS NULL THEN false
|
|
751
|
+
ELSE has_function_privilege(to_regprocedure(${signature}), 'EXECUTE') END AS ready`;
|
|
752
|
+
if (!granted?.ready) {
|
|
753
|
+
throw Object.assign(new Error(`${SLOTLOCK_USAGE_FUNCTIONS.includes(signature) ? 'SLOTLOCK_USAGE' : 'SLOTLOCK_QUOTAS'} needs ${signature}, which this database role cannot run: run \`slotlock migrate\` (or \`slotlock serve --migrate\`) with this release`), { code: 'schema_outdated' });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Each tenant's operations, recorded every 15 seconds and when the server stops (usage.ts).
|
|
758
|
+
const usage = config.usage
|
|
759
|
+
? createSlotlockUsageMeter({
|
|
760
|
+
record: (flushId, rows) => recordSlotlockUsage(sql, flushId, rows),
|
|
761
|
+
onError: (error) => log.error('usage_record_failed', {
|
|
762
|
+
error: error instanceof Error ? error.message : 'unknown',
|
|
763
|
+
}),
|
|
764
|
+
})
|
|
765
|
+
: undefined;
|
|
766
|
+
// Each tenant on a plan held to its monthly quotas, counted with the meter (quotas.ts).
|
|
767
|
+
const quotas = config.quotas && usage
|
|
768
|
+
? createSlotlockQuotas({
|
|
769
|
+
plan: (tenantRef) => readSlotlockTenantQuotas(sql, tenantRef, config.overageStopPercent !== undefined ? { overageStopPercent: config.overageStopPercent } : {}),
|
|
770
|
+
used: (tenantRef, fromDay) => readSlotlockQuotaUse(sql, tenantRef, fromDay),
|
|
771
|
+
local: (tenantRef, metric, fromDay) => usage.local(tenantRef, metric, fromDay),
|
|
772
|
+
notice: (notice) => noteSlotlockQuotaThreshold(sql, notice),
|
|
773
|
+
onError: (error) => log.error('quota_failed', { error: error instanceof Error ? error.message : 'unknown' }),
|
|
774
|
+
})
|
|
775
|
+
: undefined;
|
|
624
776
|
const { caps } = config.limits;
|
|
625
777
|
const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
|
|
626
778
|
const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
|
|
627
779
|
const publicUrl = new URL(config.publicUrl);
|
|
780
|
+
// Partner keys (`slk_partner_…`, RFC 0003 P1): the partner API, for the partner's accounts only.
|
|
781
|
+
const authenticatePartner = createSlotlockPartnerKeyAuthenticator(sql);
|
|
628
782
|
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
629
783
|
maxConcurrentLookups: caps.key_lookups,
|
|
630
784
|
maxWaitingLookups: caps.key_lookup_queue,
|
|
@@ -642,12 +796,28 @@ export async function startSlotlockServer(config, log) {
|
|
|
642
796
|
let maintenance;
|
|
643
797
|
let webhooks;
|
|
644
798
|
const basePath = publicUrl.pathname.replace(/\/$/, '');
|
|
645
|
-
|
|
799
|
+
// Counted for the scrape (SLOTLOCK_METRICS_TOKEN), an OpenTelemetry collector, or both.
|
|
800
|
+
const metrics = config.metricsToken === undefined && config.otlp?.metricsUrl === undefined
|
|
646
801
|
? undefined
|
|
647
802
|
: createSlotlockMetrics({
|
|
648
803
|
version: slotlockPackageVersion(),
|
|
649
804
|
...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
|
|
650
805
|
});
|
|
806
|
+
const knownClients = new Set(config.knownClients ?? []);
|
|
807
|
+
// The same counters to an OpenTelemetry collector each minute, when one is configured.
|
|
808
|
+
const metricsExport = metrics && config.otlp?.metricsUrl
|
|
809
|
+
? createSlotlockOtlpMetricsExporter({ ...config.otlp, metricsUrl: config.otlp.metricsUrl }, metrics, {
|
|
810
|
+
onError: (reason) => log.warn('metrics_export_failed', { reason }),
|
|
811
|
+
})
|
|
812
|
+
: undefined;
|
|
813
|
+
// A span per call to an OpenTelemetry collector, when one is configured.
|
|
814
|
+
const tracer = config.otlp?.tracesUrl
|
|
815
|
+
? createSlotlockOtlpExporter(config.otlp, {
|
|
816
|
+
onError: (reason) => log.warn('trace_export_failed', { reason }),
|
|
817
|
+
})
|
|
818
|
+
: undefined;
|
|
819
|
+
// Teams on Cloud: an organization's keys work while an owner has it turned on.
|
|
820
|
+
const organizationEnabled = config.organizations === 'enabled' ? createSlotlockOrganizationCheck(sql) : null;
|
|
651
821
|
const limits = createSlotlockRequestLimits({
|
|
652
822
|
limits: config.limits,
|
|
653
823
|
...(config.sharedLimits
|
|
@@ -666,15 +836,31 @@ export async function startSlotlockServer(config, log) {
|
|
|
666
836
|
...(config.trustedProxyHeader !== undefined
|
|
667
837
|
? { trustedProxyHeader: config.trustedProxyHeader }
|
|
668
838
|
: {}),
|
|
839
|
+
...(config.trustedProxySecret !== undefined
|
|
840
|
+
? { trustedProxySecret: config.trustedProxySecret }
|
|
841
|
+
: {}),
|
|
669
842
|
basePath,
|
|
843
|
+
...(quotas ? { quotas } : {}),
|
|
670
844
|
signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
|
|
671
845
|
// The token is compared in constant time first; only then is a key looked up. A person the
|
|
672
846
|
// dashboard no longer allows loses their keys with their session.
|
|
673
847
|
authenticate: async (request) => {
|
|
674
|
-
const principal = (await authenticateToken?.(request)) ??
|
|
675
|
-
|
|
848
|
+
const principal = (await authenticateToken?.(request)) ??
|
|
849
|
+
(await authenticatePartner(request)) ??
|
|
850
|
+
(await authenticateKey(request));
|
|
851
|
+
if (!principal || !slotlockServeAllowsTenant(config, principal.tenantRef))
|
|
852
|
+
return null;
|
|
853
|
+
const orgId = organizationEnabled ? /^github-org:(\d+)$/.exec(slotlockLiveTenant(principal.tenantRef))?.[1] : undefined;
|
|
854
|
+
if (orgId !== undefined && !(await organizationEnabled?.(orgId)))
|
|
855
|
+
return null;
|
|
856
|
+
// Slotlock-Account is a partner key's alone (RFC 0003 P3): with any other credential, or one
|
|
857
|
+
// that could not act for that account, the request is refused rather than run elsewhere.
|
|
858
|
+
if (request.headers.has(SLOTLOCK_ACCOUNT_FIELD) && principal.onBehalfOf === undefined)
|
|
676
859
|
return null;
|
|
677
|
-
|
|
860
|
+
// A partner key's own tenant holds nothing to maintain; its accounts' keys are as any key.
|
|
861
|
+
if (principal.partnerId === undefined)
|
|
862
|
+
maintenance?.touch(principal.tenantRef);
|
|
863
|
+
metrics?.client(slotlockClientLabel(request.headers.get('slotlock-client'), knownClients));
|
|
678
864
|
return principal;
|
|
679
865
|
},
|
|
680
866
|
// Counts and pseudonyms only: never an address, a credential or a tenant.
|
|
@@ -690,6 +876,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
690
876
|
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
691
877
|
});
|
|
692
878
|
const agentServer = createSlotlockAgentServer({
|
|
879
|
+
...(usage ? { onUsage: usage.count } : {}),
|
|
693
880
|
publicBaseUrl: config.publicUrl,
|
|
694
881
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
695
882
|
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
@@ -701,6 +888,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
701
888
|
authorize: async ({ principal, operation, input }) => {
|
|
702
889
|
if (!authorizeSlotlockServeOperation({ principal, operation }))
|
|
703
890
|
return false;
|
|
891
|
+
await limits.quota({ principal, operation });
|
|
704
892
|
await limits.admit({ principal, operation, input });
|
|
705
893
|
return true;
|
|
706
894
|
},
|
|
@@ -719,9 +907,13 @@ export async function startSlotlockServer(config, log) {
|
|
|
719
907
|
? { ready: true, checks: ['database'] }
|
|
720
908
|
: { ready: false, checks: ['database_unreachable'] };
|
|
721
909
|
},
|
|
910
|
+
...(tracer ? { onCallTrace: (event, trace) => tracer.record(event, trace.parentSpanId) } : {}),
|
|
722
911
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
723
912
|
onEvent: (event) => {
|
|
724
913
|
metrics?.event(event);
|
|
914
|
+
// A traced call's span is made from onCallTrace, as a child of the caller's.
|
|
915
|
+
if (!('traceId' in event && event.traceId !== undefined))
|
|
916
|
+
tracer?.record(event);
|
|
725
917
|
const { type, ...fields } = event;
|
|
726
918
|
log.info(type, snakeCaseKeys(fields));
|
|
727
919
|
},
|
|
@@ -735,10 +927,19 @@ export async function startSlotlockServer(config, log) {
|
|
|
735
927
|
application: {
|
|
736
928
|
backend: createSlotlockStoreApplicationBackend(store, {
|
|
737
929
|
maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
|
|
930
|
+
// A granted approval's booking is made as an agent's would be (RFC 0004 A4).
|
|
931
|
+
agentBackend,
|
|
932
|
+
partners: createSlotlockPartnerStore(sql, {
|
|
933
|
+
keys: createSlotlockApiKeyStore(sql),
|
|
934
|
+
eraseTenant: (tenantRef) => store.eraseTenant({ tenantRef }),
|
|
935
|
+
}),
|
|
738
936
|
}),
|
|
739
937
|
authorize: async ({ principal, operation, input }) => {
|
|
740
|
-
|
|
938
|
+
// admin, or a partner key's partner scope for the partner API: never either for the other.
|
|
939
|
+
const needed = slotlockApplicationScope(slotlockApplicationOperation(operation));
|
|
940
|
+
if (principal.scopes?.includes(needed) !== true)
|
|
741
941
|
return false;
|
|
942
|
+
await limits.quota({ principal, operation });
|
|
742
943
|
await limits.admit({ principal, operation, input });
|
|
743
944
|
return true;
|
|
744
945
|
},
|
|
@@ -765,27 +966,46 @@ export async function startSlotlockServer(config, log) {
|
|
|
765
966
|
},
|
|
766
967
|
sessionSecret: config.dashboard.sessionSecret,
|
|
767
968
|
allowedUsers: config.dashboard.allowedUsers,
|
|
969
|
+
...(config.dashboard.google ? { google: config.dashboard.google } : {}),
|
|
768
970
|
...(config.dashboard.name ? { name: config.dashboard.name } : {}),
|
|
971
|
+
...(config.dashboard.termsUrl ? { termsUrl: config.dashboard.termsUrl } : {}),
|
|
972
|
+
...(config.dashboard.privacyUrl ? { privacyUrl: config.dashboard.privacyUrl } : {}),
|
|
769
973
|
...(config.dashboard.organizations
|
|
770
974
|
? { organizations: config.dashboard.organizations }
|
|
771
975
|
: {}),
|
|
976
|
+
...(config.dashboard.organizations === 'enabled' ? { teams: createSlotlockDashboardTeams(sql) } : {}),
|
|
772
977
|
keys: createSlotlockApiKeyStore(sql),
|
|
773
|
-
resources: createSlotlockDashboardResources(store),
|
|
978
|
+
resources: createSlotlockDashboardResources(store, { agentBackend }),
|
|
774
979
|
state: createSlotlockDashboardState(sql),
|
|
775
980
|
availability: config.availability,
|
|
981
|
+
...(config.usage ? { usage: true } : {}),
|
|
982
|
+
...(config.quotas ? { quotas: true } : {}),
|
|
776
983
|
...(config.dashboard.assistantSecret !== undefined
|
|
777
984
|
? {
|
|
778
985
|
// The assistant's reads and approved writes are agent calls: the same backend,
|
|
779
986
|
// operations, rate limits and series-write slots as an API key's.
|
|
780
987
|
assistant: {
|
|
781
988
|
secret: config.dashboard.assistantSecret,
|
|
989
|
+
...(usage
|
|
990
|
+
? {
|
|
991
|
+
onUsage: usage.count,
|
|
992
|
+
onMessage: (tenantRef) => usage.add({ tenantRef, metric: 'assistant_messages', origin: 'dashboard-assistant' }),
|
|
993
|
+
}
|
|
994
|
+
: {}),
|
|
995
|
+
...(config.dashboard.assistantPreviousSecrets
|
|
996
|
+
? { previousSecrets: config.dashboard.assistantPreviousSecrets }
|
|
997
|
+
: {}),
|
|
782
998
|
...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
|
|
783
999
|
backend: agentBackend,
|
|
784
1000
|
consumeRateLimit: limits.consumeRateLimit,
|
|
785
1001
|
// Checks only: a proposal holds nothing. The series-write slot and the write
|
|
786
1002
|
// token are taken when an approved change runs (admit, consumeRateLimit).
|
|
787
1003
|
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
788
|
-
|
|
1004
|
+
// A read, or an approved write, over the tenant's monthly quota is refused.
|
|
1005
|
+
admit: async (args) => {
|
|
1006
|
+
await limits.quota(args);
|
|
1007
|
+
await limits.admit(args);
|
|
1008
|
+
},
|
|
789
1009
|
},
|
|
790
1010
|
}
|
|
791
1011
|
: {}),
|
|
@@ -811,7 +1031,12 @@ export async function startSlotlockServer(config, log) {
|
|
|
811
1031
|
store: createSlotlockMaintenanceStore(sql, caps, retention),
|
|
812
1032
|
sql,
|
|
813
1033
|
tenants: [config.tenantRef],
|
|
1034
|
+
// Every tenant with data, from the database: one whose keys have not called since this server
|
|
1035
|
+
// started is maintained too. Names only; each pass runs in its tenant's own context.
|
|
1036
|
+
discover: () => pageTenants((after) => sql `
|
|
1037
|
+
SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
|
|
814
1038
|
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
1039
|
+
...(config.usage ? { usage: true } : {}),
|
|
815
1040
|
// Counts only, never the tenant.
|
|
816
1041
|
onPass: (result) => {
|
|
817
1042
|
metrics?.maintenance(result);
|
|
@@ -824,16 +1049,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
824
1049
|
const send = createSlotlockWebhookSender({ allowPrivateNetworks: config.webhooks === 'private' });
|
|
825
1050
|
webhooks = startSlotlockWebhookDelivery({
|
|
826
1051
|
// The tenants with a webhook, from the database: a restarted server finds them all.
|
|
827
|
-
tenants:
|
|
828
|
-
|
|
829
|
-
for (;;) {
|
|
830
|
-
const page = await sql `
|
|
831
|
-
SELECT slotlock.webhook_tenants(${tenants.at(-1) ?? null}::text) AS tenant`;
|
|
832
|
-
tenants.push(...page.map(({ tenant }) => tenant));
|
|
833
|
-
if (page.length < 1_000)
|
|
834
|
-
return tenants;
|
|
835
|
-
}
|
|
836
|
-
},
|
|
1052
|
+
tenants: () => pageTenants((after) => sql `
|
|
1053
|
+
SELECT slotlock.webhook_tenants(${after}::text) AS tenant`),
|
|
837
1054
|
deliver: (tenantRef) => deliverSlotlockWebhooks({
|
|
838
1055
|
tenantRef,
|
|
839
1056
|
send,
|
|
@@ -844,10 +1061,11 @@ export async function startSlotlockServer(config, log) {
|
|
|
844
1061
|
}),
|
|
845
1062
|
},
|
|
846
1063
|
}),
|
|
847
|
-
// Counts only, never the tenant or a URL.
|
|
848
|
-
onPass: (
|
|
1064
|
+
// Counts only, never the tenant or a URL; the tenant's own count goes to its usage.
|
|
1065
|
+
onPass: (tenantRef, result) => {
|
|
849
1066
|
metrics?.webhooks(result);
|
|
850
1067
|
log.info('webhooks', { ...result });
|
|
1068
|
+
usage?.add({ tenantRef, metric: 'webhook_deliveries', origin: 'webhooks', count: result.delivered });
|
|
851
1069
|
},
|
|
852
1070
|
onError: (error) => log.error('webhooks_failed', describeError(error)),
|
|
853
1071
|
});
|
|
@@ -864,6 +1082,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
864
1082
|
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
865
1083
|
webhooks: config.webhooks,
|
|
866
1084
|
shared_limits: config.sharedLimits,
|
|
1085
|
+
usage: config.usage,
|
|
1086
|
+
quotas: config.quotas,
|
|
867
1087
|
tzdata: process.versions.tz ?? null,
|
|
868
1088
|
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
869
1089
|
limits: {
|
|
@@ -873,12 +1093,17 @@ export async function startSlotlockServer(config, log) {
|
|
|
873
1093
|
key_subscriptions: slotlockKeySubscriptions(caps),
|
|
874
1094
|
},
|
|
875
1095
|
client_address: config.trustedProxyHeader ?? 'socket',
|
|
1096
|
+
client_address_proof: config.trustedProxySecret !== undefined,
|
|
876
1097
|
});
|
|
877
1098
|
return {
|
|
878
1099
|
address,
|
|
879
1100
|
async close() {
|
|
880
1101
|
const result = await listener.close();
|
|
881
1102
|
limits.close();
|
|
1103
|
+
// What the last requests counted is recorded before the pool closes.
|
|
1104
|
+
await usage?.close();
|
|
1105
|
+
await tracer?.shutdown();
|
|
1106
|
+
await metricsExport?.shutdown();
|
|
882
1107
|
await maintenance?.close();
|
|
883
1108
|
await webhooks?.close();
|
|
884
1109
|
await sql.end({ timeout: 5 });
|
|
@@ -899,6 +1124,94 @@ function resourceLine(resource) {
|
|
|
899
1124
|
})}\n`;
|
|
900
1125
|
}
|
|
901
1126
|
/** `resource add`: create (or, for an existing reference, re-zone) one resource in the tenant. */
|
|
1127
|
+
/**
|
|
1128
|
+
* `slotlock sandbox …` (RFC 0004 A6), in the tenant SLOTLOCK_TENANT names, which must be a test
|
|
1129
|
+
* environment (`<tenant>~test`): seed a scenario, reset, fast-forward, or race agents for one slot.
|
|
1130
|
+
*/
|
|
1131
|
+
async function runSandboxCommand(subcommand, operands, values, io) {
|
|
1132
|
+
const config = readSlotlockDatabaseConfig(io.env);
|
|
1133
|
+
if (!config.tenantRef.endsWith(SLOTLOCK_TEST_TENANT_SUFFIX)) {
|
|
1134
|
+
io.stderr.write(`slotlock: the sandbox works only in a test environment: SLOTLOCK_TENANT=${config.tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}\n`);
|
|
1135
|
+
return 2;
|
|
1136
|
+
}
|
|
1137
|
+
const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
|
|
1138
|
+
const sql = connect(config.databaseUrl, subcommand === 'sandbox race' ? 20 : 1);
|
|
1139
|
+
const store = createSlotlockStore(sql);
|
|
1140
|
+
try {
|
|
1141
|
+
if (subcommand === 'sandbox seed') {
|
|
1142
|
+
const scenario = operands[1];
|
|
1143
|
+
if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario)) {
|
|
1144
|
+
io.stderr.write(`slotlock: the scenarios are ${SLOTLOCK_SANDBOX_SCENARIOS.join(', ')}\n`);
|
|
1145
|
+
return 2;
|
|
1146
|
+
}
|
|
1147
|
+
line(await seedSlotlockSandbox(store, config.tenantRef, scenario));
|
|
1148
|
+
return 0;
|
|
1149
|
+
}
|
|
1150
|
+
if (subcommand === 'sandbox reset') {
|
|
1151
|
+
line({ removed: await resetSlotlockSandbox(store, config.tenantRef) });
|
|
1152
|
+
return 0;
|
|
1153
|
+
}
|
|
1154
|
+
if (subcommand === 'sandbox fast-forward') {
|
|
1155
|
+
const minutes = /^[1-9][0-9]{0,5}$/.test(operands[1] ?? '') ? Number(operands[1]) : Number.NaN;
|
|
1156
|
+
if (!(minutes >= 1 && minutes <= 525_600)) {
|
|
1157
|
+
io.stderr.write('slotlock: fast-forward takes whole minutes, 1 to 525,600\n');
|
|
1158
|
+
return 2;
|
|
1159
|
+
}
|
|
1160
|
+
const forward = await fastForwardSlotlockSandbox(store, config.tenantRef, minutes);
|
|
1161
|
+
line({ minutes, holds: forward.holds, approvals: forward.approvals, pruned: forward.maintenance.pruned, approvals_expired: forward.maintenance.approvalsExpired });
|
|
1162
|
+
return 0;
|
|
1163
|
+
}
|
|
1164
|
+
// A race: on the resource named, from --start (an hour from tomorrow's 10:00 UTC by default).
|
|
1165
|
+
const agents = values.agents === undefined ? 100 : /^[0-9]{1,3}$/.test(values.agents) ? Number(values.agents) : Number.NaN;
|
|
1166
|
+
if (!(agents >= 2 && agents <= SLOTLOCK_SANDBOX_MAX_RACERS)) {
|
|
1167
|
+
io.stderr.write(`slotlock: --agents must be 2 to ${SLOTLOCK_SANDBOX_MAX_RACERS}\n`);
|
|
1168
|
+
return 2;
|
|
1169
|
+
}
|
|
1170
|
+
const tomorrow = new Date(Date.now() + 86_400_000);
|
|
1171
|
+
const start = values.start !== undefined
|
|
1172
|
+
? new Date(values.start)
|
|
1173
|
+
: new Date(Date.UTC(tomorrow.getUTCFullYear(), tomorrow.getUTCMonth(), tomorrow.getUTCDate(), 10));
|
|
1174
|
+
if (!Number.isFinite(start.getTime())) {
|
|
1175
|
+
io.stderr.write('slotlock: --start must be a date and time, like 2027-03-02T10:00:00Z\n');
|
|
1176
|
+
return 2;
|
|
1177
|
+
}
|
|
1178
|
+
const resource = (await store.withTenant(config.tenantRef, (tenant) => tenant.listResources({ tenantRef: config.tenantRef, limit: 1000 }))).find((candidate) => candidate.externalRef === operands[1] || candidate.id === operands[1]);
|
|
1179
|
+
if (!resource) {
|
|
1180
|
+
io.stderr.write('slotlock: no resource with that reference in the test environment\n');
|
|
1181
|
+
return 1;
|
|
1182
|
+
}
|
|
1183
|
+
const raced = await raceSlotlockSandbox({
|
|
1184
|
+
backend: createSlotlockStoreAgentBackend(store, { availabilityRules: async () => [...readAvailability(io.env)] }),
|
|
1185
|
+
tenantRef: config.tenantRef,
|
|
1186
|
+
resourceId: resource.id,
|
|
1187
|
+
start,
|
|
1188
|
+
end: new Date(start.getTime() + 3_600_000),
|
|
1189
|
+
agents,
|
|
1190
|
+
});
|
|
1191
|
+
line({
|
|
1192
|
+
agents: raced.agents,
|
|
1193
|
+
booked: raced.booked,
|
|
1194
|
+
refused: raced.refused,
|
|
1195
|
+
with_alternatives: raced.withAlternatives,
|
|
1196
|
+
server_errors: raced.serverErrors,
|
|
1197
|
+
p50_ms: raced.p50Ms,
|
|
1198
|
+
p99_ms: raced.p99Ms,
|
|
1199
|
+
max_ms: raced.maxMs,
|
|
1200
|
+
});
|
|
1201
|
+
return raced.booked === 1 && raced.serverErrors === 0 ? 0 : 1;
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
const code = error?.code;
|
|
1205
|
+
if (code === 'sandbox_not_empty') {
|
|
1206
|
+
io.stderr.write('slotlock: the test environment has resources already; run slotlock sandbox reset first\n');
|
|
1207
|
+
return 1;
|
|
1208
|
+
}
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
finally {
|
|
1212
|
+
await sql.end({ timeout: 5 });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
902
1215
|
async function addResource(config, externalRef, timezone) {
|
|
903
1216
|
const sql = connect(config.databaseUrl, 1);
|
|
904
1217
|
try {
|
|
@@ -1071,6 +1384,229 @@ async function changeResource(config, out, name, action) {
|
|
|
1071
1384
|
await sql.end({ timeout: 5 });
|
|
1072
1385
|
}
|
|
1073
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* `billing daily`: everything billing needs once a day, idempotent, for a cron job. Last month is
|
|
1389
|
+
* closed for each active partner only once it has been over for BILLING_GRACE_MS, as late counts
|
|
1390
|
+
* (bookings roll up hourly) settle first; overage goes for last month and this one; and each
|
|
1391
|
+
* partner's reconciliation mismatches of the last week are listed. Exit 1 when anything waits or
|
|
1392
|
+
* differs, so the cron job's failure is the incident.
|
|
1393
|
+
*/
|
|
1394
|
+
async function runBillingDaily(owner, io, options) {
|
|
1395
|
+
const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
|
|
1396
|
+
let attention = false;
|
|
1397
|
+
const month = slotlockPreviousMonth(options.now);
|
|
1398
|
+
const today = new Date(options.now);
|
|
1399
|
+
const monthBegan = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1);
|
|
1400
|
+
if (options.now - monthBegan < BILLING_GRACE_MS) {
|
|
1401
|
+
line({ job: 'partner_close', month, waiting_until: new Date(monthBegan + BILLING_GRACE_MS).toISOString() });
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
for (const partner of await listSlotlockPartners(owner)) {
|
|
1405
|
+
if (partner.status !== 'active')
|
|
1406
|
+
continue;
|
|
1407
|
+
const closed = await closeSlotlockPartnerMonth(owner, {
|
|
1408
|
+
partnerId: partner.id,
|
|
1409
|
+
month,
|
|
1410
|
+
now: options.now,
|
|
1411
|
+
...(options.stripeSecretKey ? { stripe: { secretKey: options.stripeSecretKey } } : {}),
|
|
1412
|
+
});
|
|
1413
|
+
line({ job: 'partner_close', partner: partner.id, month, webhook: closed.webhook, invoiced: closed.invoiced, pending: closed.pending });
|
|
1414
|
+
if (closed.webhook === 'failed' || closed.pending > 0)
|
|
1415
|
+
attention = true;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (options.overage) {
|
|
1419
|
+
if (!options.stripeSecretKey) {
|
|
1420
|
+
line({ job: 'overage', skipped: 'no STRIPE_SECRET_KEY' });
|
|
1421
|
+
attention = true;
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
for (const reported of [month, slotlockMonthOf(options.now)]) {
|
|
1425
|
+
const report = await reportSlotlockOverage(owner, {
|
|
1426
|
+
month: reported,
|
|
1427
|
+
now: options.now,
|
|
1428
|
+
stripe: { secretKey: options.stripeSecretKey, eventName: 'slotlock_operations' },
|
|
1429
|
+
});
|
|
1430
|
+
line({ job: 'overage', month: reported, sent: report.sent, failed: report.failed });
|
|
1431
|
+
if (report.failed > 0)
|
|
1432
|
+
attention = true;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const mismatches = await owner `
|
|
1437
|
+
SELECT partner_id, day::text, mismatched, worst_percent::float AS worst
|
|
1438
|
+
FROM slotlock.partner_reconciliations
|
|
1439
|
+
WHERE mismatched > 0 AND day >= current_date - 7
|
|
1440
|
+
ORDER BY day, partner_id`;
|
|
1441
|
+
for (const mismatch of mismatches) {
|
|
1442
|
+
line({ job: 'reconciliation', partner: mismatch.partner_id, day: mismatch.day, mismatched: mismatch.mismatched, worst_percent: mismatch.worst });
|
|
1443
|
+
attention = true;
|
|
1444
|
+
}
|
|
1445
|
+
line({ job: 'done', month, attention });
|
|
1446
|
+
if (attention)
|
|
1447
|
+
io.stderr.write('slotlock: billing needs attention: see the lines above; run billing daily again once it is resolved\n');
|
|
1448
|
+
return attention ? 1 : 0;
|
|
1449
|
+
}
|
|
1450
|
+
/** How long after a month ends billing daily waits to close it: late counts settle first. */
|
|
1451
|
+
const BILLING_GRACE_MS = 6 * 3_600_000;
|
|
1452
|
+
/**
|
|
1453
|
+
* `resource parent <ref> <parent-ref | none>` (RFC 0003 G8): make a resource a part of a composite,
|
|
1454
|
+
* or no part. The refusal's code when the database refuses it, false for no such resource.
|
|
1455
|
+
*/
|
|
1456
|
+
async function setResourceParent(config, out, name, parentName) {
|
|
1457
|
+
const sql = connect(config.databaseUrl, 1);
|
|
1458
|
+
try {
|
|
1459
|
+
const store = createSlotlockStore(sql);
|
|
1460
|
+
const tenantRef = config.tenantRef;
|
|
1461
|
+
const resources = await tenantResources(store, tenantRef, true);
|
|
1462
|
+
const find = (wanted) => resources.find((resource) => resource.id === wanted) ?? resources.find((resource) => resource.externalRef === wanted);
|
|
1463
|
+
const named = find(name);
|
|
1464
|
+
if (!named)
|
|
1465
|
+
return false;
|
|
1466
|
+
const parent = parentName === 'none' ? null : find(parentName);
|
|
1467
|
+
if (parent === undefined)
|
|
1468
|
+
return 'parent_not_found';
|
|
1469
|
+
try {
|
|
1470
|
+
const changed = await store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id: named.id, parentId: parent?.id ?? null }));
|
|
1471
|
+
if (!changed)
|
|
1472
|
+
return false;
|
|
1473
|
+
out.write(`${JSON.stringify({ id: changed.id, external_ref: changed.externalRef, parent_id: changed.parentId ?? null, part_index: changed.partIndex ?? null })}\n`);
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
const code = error?.code;
|
|
1478
|
+
if (typeof code === 'string' && (code.startsWith('composite_') || code === 'parent_not_found'))
|
|
1479
|
+
return code;
|
|
1480
|
+
throw error;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
finally {
|
|
1484
|
+
await sql.end({ timeout: 5 });
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* `partner …` (RFC 0003 part 4), as the schema owner (SLOTLOCK_MIGRATE_DATABASE_URL when set): make
|
|
1489
|
+
* a partner with its contract, list them, suspend one (every account with it) or make it active,
|
|
1490
|
+
* make or revoke its keys (shown once), and set its Stripe customer and its webhook (whose secret
|
|
1491
|
+
* is made here and shown once).
|
|
1492
|
+
*/
|
|
1493
|
+
async function runPartnerCommand(config, io, action, operands, contractGiven, close = { month: undefined, stripeSecretKey: undefined }) {
|
|
1494
|
+
const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
|
|
1495
|
+
const [id = '', second = ''] = operands;
|
|
1496
|
+
const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
|
|
1497
|
+
try {
|
|
1498
|
+
switch (action) {
|
|
1499
|
+
case 'create': {
|
|
1500
|
+
const text = contractGiven?.startsWith('@')
|
|
1501
|
+
? await readFile(contractGiven.slice(1), 'utf8')
|
|
1502
|
+
: (contractGiven ?? '');
|
|
1503
|
+
let contract;
|
|
1504
|
+
try {
|
|
1505
|
+
contract = JSON.parse(text);
|
|
1506
|
+
}
|
|
1507
|
+
catch {
|
|
1508
|
+
throw Object.assign(new Error('Slotlock partner contract: --contract is not JSON'), { code: 'invalid_partner_contract' });
|
|
1509
|
+
}
|
|
1510
|
+
const partner = await putSlotlockPartner(owner, { id, name: second, contract });
|
|
1511
|
+
line({ id: partner.id, name: partner.name, status: partner.status, contract: slotlockPartnerContractJson(partner.contract) });
|
|
1512
|
+
return 0;
|
|
1513
|
+
}
|
|
1514
|
+
case 'list':
|
|
1515
|
+
for (const partner of await listSlotlockPartners(owner)) {
|
|
1516
|
+
line({
|
|
1517
|
+
id: partner.id,
|
|
1518
|
+
name: partner.name,
|
|
1519
|
+
status: partner.status,
|
|
1520
|
+
stripe_customer: partner.stripeCustomer,
|
|
1521
|
+
webhook_url: partner.webhookUrl,
|
|
1522
|
+
plans: Object.keys(partner.contract.plans),
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
return 0;
|
|
1526
|
+
case 'suspend':
|
|
1527
|
+
case 'unsuspend': {
|
|
1528
|
+
const changed = await setSlotlockPartnerStatus(owner, id, action === 'suspend' ? 'suspended' : 'active');
|
|
1529
|
+
if (!changed) {
|
|
1530
|
+
io.stderr.write('slotlock: no partner with that id\n');
|
|
1531
|
+
return 1;
|
|
1532
|
+
}
|
|
1533
|
+
line({ id, status: action === 'suspend' ? 'suspended' : 'active' });
|
|
1534
|
+
return 0;
|
|
1535
|
+
}
|
|
1536
|
+
case 'key': {
|
|
1537
|
+
const created = await createSlotlockPartnerKey(owner, id, second);
|
|
1538
|
+
line({ partner: id, id: created.id, prefix: created.prefix, key: created.key });
|
|
1539
|
+
io.stderr.write('slotlock: the partner key is shown once and only its SHA-256 is stored; send it to the partner securely\n');
|
|
1540
|
+
return 0;
|
|
1541
|
+
}
|
|
1542
|
+
case 'revoke-key': {
|
|
1543
|
+
if (!(await revokeSlotlockPartnerKey(owner, id, second))) {
|
|
1544
|
+
io.stderr.write('slotlock: no key with that id for that partner\n');
|
|
1545
|
+
return 1;
|
|
1546
|
+
}
|
|
1547
|
+
line({ partner: id, id: second, revoked: true });
|
|
1548
|
+
return 0;
|
|
1549
|
+
}
|
|
1550
|
+
case 'customer': {
|
|
1551
|
+
if (!(await setSlotlockPartnerBilling(owner, id, { stripeCustomer: second }))) {
|
|
1552
|
+
io.stderr.write('slotlock: no partner with that id\n');
|
|
1553
|
+
return 1;
|
|
1554
|
+
}
|
|
1555
|
+
line({ id, stripe_customer: second });
|
|
1556
|
+
return 0;
|
|
1557
|
+
}
|
|
1558
|
+
case 'webhook': {
|
|
1559
|
+
const secret = `whsec_${randomBytes(32).toString('base64')}`;
|
|
1560
|
+
if (!(await setSlotlockPartnerBilling(owner, id, { webhookUrl: second, webhookSecret: secret }))) {
|
|
1561
|
+
io.stderr.write('slotlock: no partner with that id\n');
|
|
1562
|
+
return 1;
|
|
1563
|
+
}
|
|
1564
|
+
line({ id, webhook_url: second, webhook_secret: secret });
|
|
1565
|
+
io.stderr.write('slotlock: the webhook secret is shown once; the partner verifies usage.period_closed with it\n');
|
|
1566
|
+
return 0;
|
|
1567
|
+
}
|
|
1568
|
+
case 'close': {
|
|
1569
|
+
const closed = await closeSlotlockPartnerMonth(owner, {
|
|
1570
|
+
partnerId: id,
|
|
1571
|
+
month: close.month ?? slotlockPreviousMonth(Date.now()),
|
|
1572
|
+
...(close.stripeSecretKey ? { stripe: { secretKey: close.stripeSecretKey } } : {}),
|
|
1573
|
+
});
|
|
1574
|
+
line({
|
|
1575
|
+
partner: closed.partner,
|
|
1576
|
+
month: closed.month,
|
|
1577
|
+
accounts: closed.accounts.length,
|
|
1578
|
+
lines: closed.lines.map((entry) => ({
|
|
1579
|
+
line: entry.line,
|
|
1580
|
+
quantity: entry.quantity,
|
|
1581
|
+
amount: entry.amount,
|
|
1582
|
+
currency: entry.currency,
|
|
1583
|
+
stripe_item: entry.stripeItem,
|
|
1584
|
+
})),
|
|
1585
|
+
checksum: closed.checksum,
|
|
1586
|
+
closed_now: closed.closedNow,
|
|
1587
|
+
webhook: closed.webhook,
|
|
1588
|
+
...(closed.webhookStatus !== undefined ? { webhook_status: closed.webhookStatus } : {}),
|
|
1589
|
+
invoiced: closed.invoiced,
|
|
1590
|
+
pending: closed.pending,
|
|
1591
|
+
});
|
|
1592
|
+
if (closed.webhook === 'failed') {
|
|
1593
|
+
io.stderr.write('slotlock: the partner\'s endpoint did not take usage.period_closed; run partner close again for the same month\n');
|
|
1594
|
+
}
|
|
1595
|
+
if (closed.pending > 0) {
|
|
1596
|
+
io.stderr.write(close.stripeSecretKey
|
|
1597
|
+
? 'slotlock: some lines did not reach Stripe; run partner close again for the same month\n'
|
|
1598
|
+
: 'slotlock: lines recorded, not invoiced: set STRIPE_SECRET_KEY (and partner customer) and run it again\n');
|
|
1599
|
+
}
|
|
1600
|
+
return closed.pending > 0 || closed.webhook === 'failed' ? 1 : 0;
|
|
1601
|
+
}
|
|
1602
|
+
default:
|
|
1603
|
+
return 2;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
await owner.end({ timeout: 5 });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1074
1610
|
/**
|
|
1075
1611
|
* `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
|
|
1076
1612
|
* are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
|
|
@@ -1107,6 +1643,52 @@ async function runTenantCommand(config, out, action, reason) {
|
|
|
1107
1643
|
await sql.end({ timeout: 5 });
|
|
1108
1644
|
}
|
|
1109
1645
|
}
|
|
1646
|
+
/**
|
|
1647
|
+
* `plan status` prints the default plan and SLOTLOCK_TENANT's own; `plan default` sets or removes
|
|
1648
|
+
* the default, as the deployment role (SLOTLOCK_MIGRATE_DATABASE_URL when set), and then prints both.
|
|
1649
|
+
*/
|
|
1650
|
+
async function runPlanCommand(config, out, planDefault, set) {
|
|
1651
|
+
if (set) {
|
|
1652
|
+
const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
|
|
1653
|
+
try {
|
|
1654
|
+
await setSlotlockPlanDefault(owner, planDefault);
|
|
1655
|
+
}
|
|
1656
|
+
finally {
|
|
1657
|
+
await owner.end({ timeout: 5 });
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
const sql = connect(config.databaseUrl, 1);
|
|
1661
|
+
try {
|
|
1662
|
+
const fallback = await readSlotlockPlanDefault(sql);
|
|
1663
|
+
const own = await readSlotlockTenantPlan(sql, config.tenantRef);
|
|
1664
|
+
out.write(`${JSON.stringify({
|
|
1665
|
+
default: fallback
|
|
1666
|
+
? {
|
|
1667
|
+
plan: fallback.plan,
|
|
1668
|
+
applies_from: fallback.appliesFrom.toISOString(),
|
|
1669
|
+
resources: fallback.limits.resources,
|
|
1670
|
+
api_keys: fallback.limits.apiKeys,
|
|
1671
|
+
webhook_endpoints: fallback.limits.webhookEndpoints,
|
|
1672
|
+
}
|
|
1673
|
+
: null,
|
|
1674
|
+
tenant: config.tenantRef,
|
|
1675
|
+
plan: own
|
|
1676
|
+
? {
|
|
1677
|
+
plan: own.plan,
|
|
1678
|
+
status: own.status,
|
|
1679
|
+
resources: own.limits.resources,
|
|
1680
|
+
api_keys: own.limits.apiKeys,
|
|
1681
|
+
webhook_endpoints: own.limits.webhookEndpoints,
|
|
1682
|
+
paid: own.stripeSubscription !== null,
|
|
1683
|
+
current_period_end: own.currentPeriodEnd?.toISOString() ?? null,
|
|
1684
|
+
}
|
|
1685
|
+
: null,
|
|
1686
|
+
})}\n`);
|
|
1687
|
+
}
|
|
1688
|
+
finally {
|
|
1689
|
+
await sql.end({ timeout: 5 });
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1110
1692
|
/** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
|
|
1111
1693
|
function apiKeyLine(apiKey, key) {
|
|
1112
1694
|
return `${JSON.stringify({
|
|
@@ -1119,9 +1701,55 @@ function apiKeyLine(apiKey, key) {
|
|
|
1119
1701
|
expires_at: apiKey.expiresAt?.toISOString() ?? null,
|
|
1120
1702
|
last_used_at: apiKey.lastUsedAt?.toISOString() ?? null,
|
|
1121
1703
|
revoked_at: apiKey.revokedAt?.toISOString() ?? null,
|
|
1704
|
+
...(apiKey.resourceScope ? { resource_scope: apiKey.resourceScope } : {}),
|
|
1705
|
+
...(apiKey.limits ? { limits: apiKeyLimitsJson(apiKey.limits) } : {}),
|
|
1122
1706
|
...(key !== undefined ? { key } : {}),
|
|
1123
1707
|
})}\n`;
|
|
1124
1708
|
}
|
|
1709
|
+
/** A key's limits as the CLI prints them: snake_case, as the API names them. */
|
|
1710
|
+
function apiKeyLimitsJson(limits) {
|
|
1711
|
+
return {
|
|
1712
|
+
...(limits.maxHolds !== undefined ? { max_holds: limits.maxHolds } : {}),
|
|
1713
|
+
...(limits.maxHeldHours !== undefined ? { max_held_hours: limits.maxHeldHours } : {}),
|
|
1714
|
+
...(limits.requestsPerMinute !== undefined ? { requests_per_minute: limits.requestsPerMinute } : {}),
|
|
1715
|
+
...(limits.requiresApproval ? { requires_approval: true } : {}),
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/** `--max-holds`, `--max-held-hours`, `--requests-per-minute`, `--requires-approval` (RFC 0004 A5). */
|
|
1719
|
+
function parseKeyLimits(values) {
|
|
1720
|
+
const whole = (option, value, most) => {
|
|
1721
|
+
if (value === undefined)
|
|
1722
|
+
return undefined;
|
|
1723
|
+
const parsed = /^[0-9]{1,7}$/.test(value) ? Number(value) : Number.NaN;
|
|
1724
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > most) {
|
|
1725
|
+
throw new Error(`--${option} must be a whole number from 1 to ${most.toLocaleString('en-US')}`);
|
|
1726
|
+
}
|
|
1727
|
+
return parsed;
|
|
1728
|
+
};
|
|
1729
|
+
const maxHolds = whole('max-holds', values['max-holds'], 10_000);
|
|
1730
|
+
const maxHeldHours = whole('max-held-hours', values['max-held-hours'], 876_000);
|
|
1731
|
+
const requestsPerMinute = whole('requests-per-minute', values['requests-per-minute'], 100_000);
|
|
1732
|
+
return {
|
|
1733
|
+
...(maxHolds !== undefined ? { maxHolds } : {}),
|
|
1734
|
+
...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
|
|
1735
|
+
...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
|
|
1736
|
+
...(values['requires-approval'] === true ? { requiresApproval: true } : {}),
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
/** `--locations` and `--groups`: references, comma-separated; neither for a key of the whole tenant. */
|
|
1740
|
+
function parseKeyResourceScope(locations, groups) {
|
|
1741
|
+
if (locations === undefined && groups === undefined)
|
|
1742
|
+
return undefined;
|
|
1743
|
+
const list = (value) => (value ?? '')
|
|
1744
|
+
.split(',')
|
|
1745
|
+
.map((ref) => ref.trim())
|
|
1746
|
+
.filter((ref) => ref !== '');
|
|
1747
|
+
const scope = { locations: list(locations), groups: list(groups) };
|
|
1748
|
+
if (!isSlotlockResourceScope(scope)) {
|
|
1749
|
+
throw new Error('--locations and --groups take up to 20 location or group references each, at least one in all');
|
|
1750
|
+
}
|
|
1751
|
+
return scope;
|
|
1752
|
+
}
|
|
1125
1753
|
/** `--scope`: read, write and admin, comma-separated; read,write unless given. */
|
|
1126
1754
|
function parseKeyScopes(value) {
|
|
1127
1755
|
if (value === undefined)
|
|
@@ -1151,6 +1779,7 @@ async function maintainTenant(config, caps) {
|
|
|
1151
1779
|
staleSources: 0,
|
|
1152
1780
|
noShows: 0,
|
|
1153
1781
|
waitlistOffers: 0,
|
|
1782
|
+
approvalsExpired: 0,
|
|
1154
1783
|
capped: true,
|
|
1155
1784
|
passes: 0,
|
|
1156
1785
|
};
|
|
@@ -1165,6 +1794,7 @@ async function maintainTenant(config, caps) {
|
|
|
1165
1794
|
sum.staleSources += pass.staleSources;
|
|
1166
1795
|
sum.noShows += pass.noShows;
|
|
1167
1796
|
sum.waitlistOffers += pass.waitlistOffers;
|
|
1797
|
+
sum.approvalsExpired += pass.approvalsExpired;
|
|
1168
1798
|
// A capped pass that got nowhere met only series it cannot extend. While its roll stopped
|
|
1169
1799
|
// before the series it had put back, the next pass meets new ones (and perhaps a healthy
|
|
1170
1800
|
// one); once it reached them, another pass would meet them again.
|
|
@@ -1226,7 +1856,7 @@ Commands:
|
|
|
1226
1856
|
migrate Apply the schema and row-level security, then grant the serving
|
|
1227
1857
|
role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
|
|
1228
1858
|
serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
|
|
1229
|
-
maintains
|
|
1859
|
+
maintains every tenant with data every hour
|
|
1230
1860
|
maintain Run maintenance for the tenant now: extend recurring events to the
|
|
1231
1861
|
rolling horizon and prune past the retention window (for cron)
|
|
1232
1862
|
resource add <ref> [--timezone <IANA zone>]
|
|
@@ -1235,6 +1865,10 @@ Commands:
|
|
|
1235
1865
|
resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
|
|
1236
1866
|
no longer listed; what is on it stays
|
|
1237
1867
|
resource restore <ref> Put an archived resource back in use
|
|
1868
|
+
resource parent <ref> (<parent-ref> | none)
|
|
1869
|
+
Make a resource a part of a composite (a room of a hall): booking
|
|
1870
|
+
the whole then takes every part, and a part's booking keeps the
|
|
1871
|
+
whole from being booked; none makes it no part
|
|
1238
1872
|
resource delete <ref> Delete a resource and its history; refused while a booking, hold
|
|
1239
1873
|
or event on it has not ended
|
|
1240
1874
|
hours show [--resource <ref>]
|
|
@@ -1249,13 +1883,63 @@ Commands:
|
|
|
1249
1883
|
rules clear --resource <ref>
|
|
1250
1884
|
Remove the resource's booking rules
|
|
1251
1885
|
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
1886
|
+
[--locations <ref,...>] [--groups <ref,...>]
|
|
1252
1887
|
Create an API key for the tenant (default scope read,write, no
|
|
1253
1888
|
expiry; admin adds the application API) and print it once; only its
|
|
1254
|
-
SHA-256 is stored
|
|
1889
|
+
SHA-256 is stored. With --locations or --groups, the key reaches
|
|
1890
|
+
only the resources there (each location with those in it), read and
|
|
1891
|
+
write only
|
|
1892
|
+
[--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]
|
|
1893
|
+
[--requires-approval]
|
|
1894
|
+
... and hold the key below the server's limits: its live holds, the
|
|
1895
|
+
hours they hold, its requests a minute, and every booking it makes
|
|
1896
|
+
waiting for a person (the dashboard's Approvals)
|
|
1897
|
+
key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]
|
|
1898
|
+
[--requires-approval]
|
|
1899
|
+
Replace an active key's limits; none at all gives it the server's
|
|
1255
1900
|
key list Print the tenant's API keys, one JSON object per line
|
|
1256
1901
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
1257
1902
|
working at once, and the key keeps its id and what it booked
|
|
1258
1903
|
key revoke <id> Revoke one of the tenant's API keys at once
|
|
1904
|
+
sandbox seed <car-rental|clinic|desks>
|
|
1905
|
+
Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a
|
|
1906
|
+
scenario: locations, groups, resources, hours, rules and bookings
|
|
1907
|
+
sandbox reset Empty the test environment; its keys, webhooks and plan stay
|
|
1908
|
+
sandbox fast-forward <minutes>
|
|
1909
|
+
Make what would lapse in the test environment within that time lapse
|
|
1910
|
+
now (holds, offers, approvals), then run maintenance
|
|
1911
|
+
sandbox race <resource-ref> [--agents <n>] [--start <date-time>]
|
|
1912
|
+
Race agents (default 100) for one hour on a test resource and print who
|
|
1913
|
+
booked, who was refused and why, and how long the answers took
|
|
1914
|
+
partner create <id> <name> --contract <json | @file>
|
|
1915
|
+
Create a partner reselling Slotlock, or change its name and contract:
|
|
1916
|
+
its plans (limits and quotas), its accounts' key scopes and its prices
|
|
1917
|
+
(as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)
|
|
1918
|
+
partner list Print the partners, one JSON object per line
|
|
1919
|
+
partner suspend <id> Suspend a partner: every account with it, and its keys stop
|
|
1920
|
+
partner unsuspend <id> Make a suspended partner active again
|
|
1921
|
+
partner key <id> <name> Create a partner key (slk_partner_…) and print it once
|
|
1922
|
+
partner revoke-key <id> <key-id>
|
|
1923
|
+
Revoke one of a partner's keys at once
|
|
1924
|
+
partner customer <id> <cus_…>
|
|
1925
|
+
The Stripe customer its monthly invoice goes to
|
|
1926
|
+
partner webhook <id> <https-url>
|
|
1927
|
+
Where usage.period_closed goes; prints its signing secret once
|
|
1928
|
+
billing daily Once a day (cron), as the schema owner: close last month for every
|
|
1929
|
+
active partner once it has been over for 6 hours, report overage
|
|
1930
|
+
when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the
|
|
1931
|
+
partners' reconciliation mismatches of the last 7 days; exits 1 when
|
|
1932
|
+
anything waits or differs. Safe to run again
|
|
1933
|
+
overage report [--month <YYYY-MM>] [--event <name>]
|
|
1934
|
+
With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's
|
|
1935
|
+
operations above its quota this month (or --month) not yet sent, as
|
|
1936
|
+
Billing Meter events (--event, default slotlock_operations), once
|
|
1937
|
+
(as the schema owner, with STRIPE_SECRET_KEY)
|
|
1938
|
+
partner close <id> [--month <YYYY-MM>]
|
|
1939
|
+
Close an ended month (default: last month): price its accounts' use
|
|
1940
|
+
with the contract, record the lines once, send usage.period_closed,
|
|
1941
|
+
and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe
|
|
1942
|
+
invoice; safe to run again
|
|
1259
1943
|
tenant status Print whether the tenant is suspended
|
|
1260
1944
|
tenant suspend [--reason <text>]
|
|
1261
1945
|
Suspend the tenant: it may read, and writes nothing; its keys get
|
|
@@ -1264,7 +1948,13 @@ Commands:
|
|
|
1264
1948
|
tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
|
|
1265
1949
|
tenant erase --confirm <tenant>
|
|
1266
1950
|
Delete everything the tenant has, its API keys included, for good;
|
|
1267
|
-
--confirm repeats SLOTLOCK_TENANT
|
|
1951
|
+
--confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is
|
|
1952
|
+
paid for (cancel the subscription first)
|
|
1953
|
+
plan status Print the deployment's default plan and the tenant's own (hosted)
|
|
1954
|
+
plan default (free | developer | none) [--from <date>]
|
|
1955
|
+
Put every tenant without a plan of its own on this one from --from
|
|
1956
|
+
(an ISO date or time; default now), or none for no default; a new
|
|
1957
|
+
tenant gets it at once (as the schema owner, like tenant suspend)
|
|
1268
1958
|
healthcheck Exit 0 when the local server reports ready
|
|
1269
1959
|
|
|
1270
1960
|
Options:
|
|
@@ -1294,6 +1984,16 @@ Environment:
|
|
|
1294
1984
|
SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
|
|
1295
1985
|
and retired answers (never under 30) (1-3650, default 30;
|
|
1296
1986
|
serve and maintain)
|
|
1987
|
+
SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API
|
|
1988
|
+
key and surface (slotlock.usage_daily, kept 400 days); off
|
|
1989
|
+
(default): record nothing
|
|
1990
|
+
SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its
|
|
1991
|
+
operations or bookings this month (quota_exceeded, 429); off
|
|
1992
|
+
(default): no quota
|
|
1993
|
+
SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying
|
|
1994
|
+
Developer tenant is refused only past that share of its
|
|
1995
|
+
operations, and the rest above its quota is billed (overage
|
|
1996
|
+
report); needs SLOTLOCK_QUOTAS=on
|
|
1297
1997
|
SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
|
|
1298
1998
|
database, so N servers allow a tenant its rate once; off (default):
|
|
1299
1999
|
each server keeps its own
|
|
@@ -1321,6 +2021,17 @@ Environment:
|
|
|
1321
2021
|
SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
|
|
1322
2022
|
such as CF-Connecting-IP (default: the connection's address);
|
|
1323
2023
|
set it only when every request comes through that proxy
|
|
2024
|
+
SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,
|
|
2025
|
+
32+ random characters: without it there, the field above is
|
|
2026
|
+
ignored and the connection's address counts
|
|
2027
|
+
SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from
|
|
2028
|
+
their Slotlock-Client field (any other: other; none sent: none)
|
|
2029
|
+
OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per
|
|
2030
|
+
call is sent to <endpoint>/v1/traces (off without it)
|
|
2031
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces
|
|
2032
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)
|
|
2033
|
+
OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key
|
|
2034
|
+
OTEL_SERVICE_NAME service.name of the spans (default slotlock)
|
|
1324
2035
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
1325
2036
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
1326
2037
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
@@ -1328,13 +2039,24 @@ Environment:
|
|
|
1328
2039
|
SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
|
|
1329
2040
|
model API key with it, 32+ random characters (dashboard; off
|
|
1330
2041
|
without it)
|
|
2042
|
+
SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,
|
|
2043
|
+
comma-separated, newest first: keys sealed under one still open
|
|
2044
|
+
and are sealed again under the current secret when next used
|
|
1331
2045
|
SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
|
|
1332
2046
|
resolves to, a loopback, private, link-local, CGNAT, unique-local
|
|
1333
2047
|
or metadata address; on: allow them, for a self-hosted model
|
|
1334
2048
|
(Ollama, vLLM) on this machine or network
|
|
1335
|
-
|
|
1336
|
-
|
|
2049
|
+
SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with
|
|
2050
|
+
redirect URI .../dashboard/callback (not with organization ids)
|
|
2051
|
+
SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret
|
|
2052
|
+
SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work
|
|
2053
|
+
SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;
|
|
2054
|
+
or enabled: each person's own tenant, plus the organizations an
|
|
2055
|
+
owner turns on from the dashboard's Teams page
|
|
1337
2056
|
SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
|
|
2057
|
+
SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the
|
|
2058
|
+
sign-in page (none named without it)
|
|
2059
|
+
SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links
|
|
1338
2060
|
HOST Listen address (default 127.0.0.1)
|
|
1339
2061
|
PORT Listen port (default 8080)
|
|
1340
2062
|
|
|
@@ -1375,7 +2097,12 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1375
2097
|
command === 'key' ||
|
|
1376
2098
|
command === 'hours' ||
|
|
1377
2099
|
command === 'rules' ||
|
|
1378
|
-
command === 'tenant'
|
|
2100
|
+
command === 'tenant' ||
|
|
2101
|
+
command === 'plan' ||
|
|
2102
|
+
command === 'partner' ||
|
|
2103
|
+
command === 'overage' ||
|
|
2104
|
+
command === 'billing' ||
|
|
2105
|
+
command === 'sandbox'
|
|
1379
2106
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
1380
2107
|
: command;
|
|
1381
2108
|
const expectedOperands = {
|
|
@@ -1386,21 +2113,40 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1386
2113
|
'resource add': 2,
|
|
1387
2114
|
'resource list': 1,
|
|
1388
2115
|
'resource archive': 2,
|
|
2116
|
+
'resource parent': 3,
|
|
1389
2117
|
'resource restore': 2,
|
|
1390
2118
|
'resource delete': 2,
|
|
1391
2119
|
'tenant status': 1,
|
|
1392
2120
|
'tenant suspend': 1,
|
|
1393
2121
|
'tenant unsuspend': 1,
|
|
1394
2122
|
'tenant erase': 1,
|
|
2123
|
+
'plan status': 1,
|
|
2124
|
+
'plan default': 2,
|
|
1395
2125
|
'key create': 2,
|
|
1396
2126
|
'key list': 1,
|
|
1397
2127
|
'key rotate': 2,
|
|
1398
2128
|
'key revoke': 2,
|
|
2129
|
+
'key limits': 2,
|
|
2130
|
+
'sandbox seed': 2,
|
|
2131
|
+
'sandbox reset': 1,
|
|
2132
|
+
'sandbox fast-forward': 2,
|
|
2133
|
+
'sandbox race': 2,
|
|
1399
2134
|
'hours show': 1,
|
|
1400
2135
|
'hours set': 2,
|
|
1401
2136
|
'hours clear': 1,
|
|
1402
2137
|
'rules set': 2,
|
|
1403
2138
|
'rules clear': 1,
|
|
2139
|
+
'partner create': 3,
|
|
2140
|
+
'partner list': 1,
|
|
2141
|
+
'partner suspend': 2,
|
|
2142
|
+
'partner unsuspend': 2,
|
|
2143
|
+
'partner key': 3,
|
|
2144
|
+
'partner revoke-key': 3,
|
|
2145
|
+
'partner customer': 3,
|
|
2146
|
+
'partner webhook': 3,
|
|
2147
|
+
'partner close': 2,
|
|
2148
|
+
'overage report': 1,
|
|
2149
|
+
'billing daily': 1,
|
|
1404
2150
|
};
|
|
1405
2151
|
const expected = expectedOperands[subcommand];
|
|
1406
2152
|
if (expected === undefined)
|
|
@@ -1417,12 +2163,45 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1417
2163
|
return usage(`--${option} applies only to key create`);
|
|
1418
2164
|
}
|
|
1419
2165
|
}
|
|
2166
|
+
if (values.month !== undefined && subcommand !== 'partner close' && subcommand !== 'overage report') {
|
|
2167
|
+
return usage('--month applies only to partner close and overage report');
|
|
2168
|
+
}
|
|
2169
|
+
if (values.event !== undefined && subcommand !== 'overage report') {
|
|
2170
|
+
return usage('--event applies only to overage report');
|
|
2171
|
+
}
|
|
2172
|
+
if (values.month !== undefined && !/^\d{4}-(0[1-9]|1[0-2])$/.test(values.month)) {
|
|
2173
|
+
return usage('--month must be a month, like 2026-10');
|
|
2174
|
+
}
|
|
2175
|
+
if (values.contract !== undefined && subcommand !== 'partner create') {
|
|
2176
|
+
return usage('--contract applies only to partner create');
|
|
2177
|
+
}
|
|
2178
|
+
if (subcommand === 'partner create' && values.contract === undefined) {
|
|
2179
|
+
return usage('partner create needs --contract <json>, or --contract @<file>');
|
|
2180
|
+
}
|
|
1420
2181
|
if (values.reason !== undefined && subcommand !== 'tenant suspend') {
|
|
1421
2182
|
return usage('--reason applies only to tenant suspend');
|
|
1422
2183
|
}
|
|
1423
2184
|
if (values.confirm !== undefined && subcommand !== 'tenant erase') {
|
|
1424
2185
|
return usage('--confirm applies only to tenant erase');
|
|
1425
2186
|
}
|
|
2187
|
+
if (values.from !== undefined && subcommand !== 'plan default') {
|
|
2188
|
+
return usage('--from applies only to plan default');
|
|
2189
|
+
}
|
|
2190
|
+
let planDefault = null;
|
|
2191
|
+
if (subcommand === 'plan default') {
|
|
2192
|
+
const named = operands[1];
|
|
2193
|
+
if (named !== 'free' && named !== 'developer' && named !== 'none') {
|
|
2194
|
+
return usage('plan default takes free, developer or none');
|
|
2195
|
+
}
|
|
2196
|
+
if (named === 'none' && values.from !== undefined)
|
|
2197
|
+
return usage('--from applies only to a plan');
|
|
2198
|
+
const appliesFrom = values.from === undefined ? new Date() : new Date(values.from);
|
|
2199
|
+
if (Number.isNaN(appliesFrom.getTime()) || !/^\d{4}-\d{2}-\d{2}/.test(values.from ?? '2000-01-01')) {
|
|
2200
|
+
return usage('--from must be an ISO date or time, like 2026-12-09 or 2026-12-09T00:00:00Z');
|
|
2201
|
+
}
|
|
2202
|
+
if (named !== 'none')
|
|
2203
|
+
planDefault = { plan: named, appliesFrom };
|
|
2204
|
+
}
|
|
1426
2205
|
if (subcommand === 'tenant erase' && values.confirm === undefined) {
|
|
1427
2206
|
return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
|
|
1428
2207
|
}
|
|
@@ -1470,15 +2249,36 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1470
2249
|
}
|
|
1471
2250
|
let keyScopes = [];
|
|
1472
2251
|
let keyLifetimeDays;
|
|
2252
|
+
let keyResourceScope;
|
|
2253
|
+
let keyLimits = {};
|
|
1473
2254
|
if (subcommand === 'key create') {
|
|
1474
2255
|
try {
|
|
1475
2256
|
keyScopes = parseKeyScopes(values.scope);
|
|
1476
2257
|
keyLifetimeDays = parseKeyLifetime(values['expires-in-days']);
|
|
2258
|
+
keyResourceScope = parseKeyResourceScope(values.locations, values.groups);
|
|
1477
2259
|
}
|
|
1478
2260
|
catch (error) {
|
|
1479
2261
|
return usage(error.message);
|
|
1480
2262
|
}
|
|
1481
2263
|
}
|
|
2264
|
+
else if (values.locations !== undefined || values.groups !== undefined) {
|
|
2265
|
+
return usage('--locations and --groups are for key create');
|
|
2266
|
+
}
|
|
2267
|
+
if ((values.agents !== undefined || values.start !== undefined) && subcommand !== 'sandbox race') {
|
|
2268
|
+
return usage('--agents and --start apply only to sandbox race');
|
|
2269
|
+
}
|
|
2270
|
+
const limitOptions = ['max-holds', 'max-held-hours', 'requests-per-minute', 'requires-approval'];
|
|
2271
|
+
if (subcommand === 'key create' || subcommand === 'key limits') {
|
|
2272
|
+
try {
|
|
2273
|
+
keyLimits = parseKeyLimits(values);
|
|
2274
|
+
}
|
|
2275
|
+
catch (error) {
|
|
2276
|
+
return usage(error.message);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
else if (limitOptions.some((option) => values[option] !== undefined)) {
|
|
2280
|
+
return usage('--max-holds, --max-held-hours, --requests-per-minute and --requires-approval are for key create and key limits');
|
|
2281
|
+
}
|
|
1482
2282
|
const log = createLogger(io);
|
|
1483
2283
|
try {
|
|
1484
2284
|
switch (subcommand) {
|
|
@@ -1507,6 +2307,11 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1507
2307
|
io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1508
2308
|
return 0;
|
|
1509
2309
|
}
|
|
2310
|
+
case 'sandbox seed':
|
|
2311
|
+
case 'sandbox reset':
|
|
2312
|
+
case 'sandbox fast-forward':
|
|
2313
|
+
case 'sandbox race':
|
|
2314
|
+
return await runSandboxCommand(subcommand, operands, values, io);
|
|
1510
2315
|
case 'resource add': {
|
|
1511
2316
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1512
2317
|
const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
|
|
@@ -1516,6 +2321,78 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1516
2321
|
case 'resource list':
|
|
1517
2322
|
await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
|
|
1518
2323
|
return 0;
|
|
2324
|
+
case 'billing daily': {
|
|
2325
|
+
const database = readSlotlockDatabaseConfig(io.env);
|
|
2326
|
+
const owner = connect(database.migrateDatabaseUrl ?? database.databaseUrl, 1);
|
|
2327
|
+
try {
|
|
2328
|
+
return await runBillingDaily(owner, io, {
|
|
2329
|
+
now: Date.now(),
|
|
2330
|
+
stripeSecretKey: io.env.STRIPE_SECRET_KEY?.trim() || undefined,
|
|
2331
|
+
overage: (io.env.SLOTLOCK_OVERAGE?.trim() || 'off') !== 'off',
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
catch (error) {
|
|
2335
|
+
if (error?.code === '42501') {
|
|
2336
|
+
io.stderr.write('slotlock: only the schema owner runs billing; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
2337
|
+
return 1;
|
|
2338
|
+
}
|
|
2339
|
+
throw error;
|
|
2340
|
+
}
|
|
2341
|
+
finally {
|
|
2342
|
+
await owner.end({ timeout: 5 });
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
case 'overage report': {
|
|
2346
|
+
const secretKey = io.env.STRIPE_SECRET_KEY?.trim();
|
|
2347
|
+
if (!secretKey) {
|
|
2348
|
+
io.stderr.write('slotlock: overage report needs STRIPE_SECRET_KEY, with write access to meter events\n');
|
|
2349
|
+
return 1;
|
|
2350
|
+
}
|
|
2351
|
+
const database = readSlotlockDatabaseConfig(io.env);
|
|
2352
|
+
const owner = connect(database.migrateDatabaseUrl ?? database.databaseUrl, 1);
|
|
2353
|
+
try {
|
|
2354
|
+
const report = await reportSlotlockOverage(owner, {
|
|
2355
|
+
month: values.month ?? slotlockMonthOf(Date.now()),
|
|
2356
|
+
stripe: { secretKey, eventName: values.event ?? 'slotlock_operations' },
|
|
2357
|
+
});
|
|
2358
|
+
io.stdout.write(`${JSON.stringify({ month: report.month, tenants: report.tenants.length, sent: report.sent, failed: report.failed })}\n`);
|
|
2359
|
+
if (report.failed > 0) {
|
|
2360
|
+
io.stderr.write('slotlock: Stripe did not take some events; run overage report again\n');
|
|
2361
|
+
return 1;
|
|
2362
|
+
}
|
|
2363
|
+
return 0;
|
|
2364
|
+
}
|
|
2365
|
+
catch (error) {
|
|
2366
|
+
const code = error?.code;
|
|
2367
|
+
if (code === 'invalid_meter_event' || code === 'invalid_month') {
|
|
2368
|
+
io.stderr.write(`slotlock: ${error.message}\n`);
|
|
2369
|
+
return 2;
|
|
2370
|
+
}
|
|
2371
|
+
if (code === '42501') {
|
|
2372
|
+
io.stderr.write('slotlock: only the schema owner reports overage; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
2373
|
+
return 1;
|
|
2374
|
+
}
|
|
2375
|
+
throw error;
|
|
2376
|
+
}
|
|
2377
|
+
finally {
|
|
2378
|
+
await owner.end({ timeout: 5 });
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
case 'resource parent': {
|
|
2382
|
+
const changed = await setResourceParent(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], operands[2]);
|
|
2383
|
+
if (changed === true)
|
|
2384
|
+
return 0;
|
|
2385
|
+
io.stderr.write(`slotlock: ${changed === false
|
|
2386
|
+
? 'no resource with that reference or id in this tenant'
|
|
2387
|
+
: changed === 'parent_not_found'
|
|
2388
|
+
? 'no parent resource with that reference or id in this tenant'
|
|
2389
|
+
: changed === 'composite_busy'
|
|
2390
|
+
? 'one side has a booking or live hold still to come; a composite changes only while neither does'
|
|
2391
|
+
: changed === 'composite_too_deep'
|
|
2392
|
+
? 'composites are one level deep: a part has no parts, and a parent is no part'
|
|
2393
|
+
: 'a composite has at most 50 parts'}\n`);
|
|
2394
|
+
return 1;
|
|
2395
|
+
}
|
|
1519
2396
|
case 'resource archive':
|
|
1520
2397
|
case 'resource restore':
|
|
1521
2398
|
case 'resource delete': {
|
|
@@ -1551,6 +2428,53 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1551
2428
|
if (code === 'invalid_suspension_reason') {
|
|
1552
2429
|
return usage('--reason must be 1-500 characters without control characters');
|
|
1553
2430
|
}
|
|
2431
|
+
if (code === 'billing_active') {
|
|
2432
|
+
io.stderr.write('slotlock: the tenant has a live subscription; cancel it in Stripe first (its webhook moves the tenant to Free), then erase\n');
|
|
2433
|
+
return 1;
|
|
2434
|
+
}
|
|
2435
|
+
throw error;
|
|
2436
|
+
}
|
|
2437
|
+
return 0;
|
|
2438
|
+
}
|
|
2439
|
+
case 'partner create':
|
|
2440
|
+
case 'partner list':
|
|
2441
|
+
case 'partner suspend':
|
|
2442
|
+
case 'partner unsuspend':
|
|
2443
|
+
case 'partner key':
|
|
2444
|
+
case 'partner revoke-key':
|
|
2445
|
+
case 'partner customer':
|
|
2446
|
+
case 'partner webhook':
|
|
2447
|
+
case 'partner close': {
|
|
2448
|
+
try {
|
|
2449
|
+
return await runPartnerCommand(readSlotlockDatabaseConfig(io.env), io, subcommand.slice('partner '.length), operands.slice(1), values.contract, { month: values.month, stripeSecretKey: io.env.STRIPE_SECRET_KEY?.trim() || undefined });
|
|
2450
|
+
}
|
|
2451
|
+
catch (error) {
|
|
2452
|
+
const code = error?.code;
|
|
2453
|
+
if (code === 'partner_not_found') {
|
|
2454
|
+
io.stderr.write('slotlock: no partner with that id\n');
|
|
2455
|
+
return 1;
|
|
2456
|
+
}
|
|
2457
|
+
if (code === 'invalid_partner_contract' || code === 'invalid_partner' || code === 'month_not_ended' || code === 'invalid_month') {
|
|
2458
|
+
io.stderr.write(`slotlock: ${error.message}\n`);
|
|
2459
|
+
return 2;
|
|
2460
|
+
}
|
|
2461
|
+
if (code === '42501') {
|
|
2462
|
+
io.stderr.write('slotlock: only the schema owner runs partner commands; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
2463
|
+
return 1;
|
|
2464
|
+
}
|
|
2465
|
+
throw error;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
case 'plan status':
|
|
2469
|
+
case 'plan default': {
|
|
2470
|
+
try {
|
|
2471
|
+
await runPlanCommand(readSlotlockDatabaseConfig(io.env), io.stdout, planDefault, subcommand === 'plan default');
|
|
2472
|
+
}
|
|
2473
|
+
catch (error) {
|
|
2474
|
+
if (error?.code === 'plan_default_not_permitted') {
|
|
2475
|
+
io.stderr.write('slotlock: only the schema owner sets the default plan; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
2476
|
+
return 1;
|
|
2477
|
+
}
|
|
1554
2478
|
throw error;
|
|
1555
2479
|
}
|
|
1556
2480
|
return 0;
|
|
@@ -1586,6 +2510,8 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1586
2510
|
name: operands[1],
|
|
1587
2511
|
scopes: keyScopes,
|
|
1588
2512
|
createdBy: CLI_KEY_CREATOR,
|
|
2513
|
+
...(keyResourceScope ? { resourceScope: keyResourceScope } : {}),
|
|
2514
|
+
...(Object.keys(keyLimits).length > 0 ? { limits: keyLimits } : {}),
|
|
1589
2515
|
...(keyLifetimeDays !== undefined
|
|
1590
2516
|
? { expiresAt: new Date(Date.now() + keyLifetimeDays * DAY_MS) }
|
|
1591
2517
|
: {}),
|
|
@@ -1612,6 +2538,17 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1612
2538
|
io.stderr.write('slotlock: the new key is shown once and the old one no longer works; keep it in a secret manager\n');
|
|
1613
2539
|
return 0;
|
|
1614
2540
|
}
|
|
2541
|
+
case 'key limits': {
|
|
2542
|
+
const config = readSlotlockDatabaseConfig(io.env);
|
|
2543
|
+
const id = operands[1];
|
|
2544
|
+
const set = await withApiKeys(config, (keys) => keys.setLimits({ tenantRef: config.tenantRef, id, limits: keyLimits }));
|
|
2545
|
+
if (!set) {
|
|
2546
|
+
io.stderr.write('slotlock: no active API key with that id in this tenant\n');
|
|
2547
|
+
return 1;
|
|
2548
|
+
}
|
|
2549
|
+
io.stdout.write(`${JSON.stringify({ id, limits: apiKeyLimitsJson(keyLimits) })}\n`);
|
|
2550
|
+
return 0;
|
|
2551
|
+
}
|
|
1615
2552
|
case 'key revoke': {
|
|
1616
2553
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1617
2554
|
const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
|
|
@@ -1645,10 +2582,22 @@ function parseCliArguments(argv) {
|
|
|
1645
2582
|
timezone: { type: 'string' },
|
|
1646
2583
|
scope: { type: 'string' },
|
|
1647
2584
|
'expires-in-days': { type: 'string' },
|
|
2585
|
+
locations: { type: 'string' },
|
|
2586
|
+
groups: { type: 'string' },
|
|
2587
|
+
'max-holds': { type: 'string' },
|
|
2588
|
+
'max-held-hours': { type: 'string' },
|
|
2589
|
+
'requests-per-minute': { type: 'string' },
|
|
2590
|
+
'requires-approval': { type: 'boolean' },
|
|
2591
|
+
agents: { type: 'string' },
|
|
2592
|
+
start: { type: 'string' },
|
|
2593
|
+
contract: { type: 'string' },
|
|
2594
|
+
month: { type: 'string' },
|
|
2595
|
+
event: { type: 'string' },
|
|
1648
2596
|
resource: { type: 'string' },
|
|
1649
2597
|
all: { type: 'boolean' },
|
|
1650
2598
|
reason: { type: 'string' },
|
|
1651
2599
|
confirm: { type: 'string' },
|
|
2600
|
+
from: { type: 'string' },
|
|
1652
2601
|
help: { type: 'boolean', short: 'h' },
|
|
1653
2602
|
version: { type: 'boolean', short: 'v' },
|
|
1654
2603
|
},
|