slotlock 0.6.3 → 0.8.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 +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- 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.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 +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
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
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,17 +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',
|
|
43
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',
|
|
44
68
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
45
69
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
70
|
+
'SLOTLOCK_GOOGLE_CLIENT_ID',
|
|
71
|
+
'SLOTLOCK_GOOGLE_CLIENT_SECRET',
|
|
46
72
|
'SLOTLOCK_SESSION_SECRET',
|
|
47
73
|
'SLOTLOCK_ASSISTANT_SECRET',
|
|
74
|
+
'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS',
|
|
48
75
|
'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
|
|
49
76
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
50
77
|
'SLOTLOCK_DASHBOARD_ORGS',
|
|
51
78
|
'SLOTLOCK_DASHBOARD_NAME',
|
|
79
|
+
'SLOTLOCK_DASHBOARD_TERMS_URL',
|
|
80
|
+
'SLOTLOCK_DASHBOARD_PRIVACY_URL',
|
|
52
81
|
'HOST',
|
|
53
82
|
'PORT',
|
|
54
83
|
]);
|
|
@@ -65,7 +94,7 @@ const DAY_MS = 86_400_000;
|
|
|
65
94
|
* every GitHub user gives each a tenant, so the cap keeps one from filling the database.
|
|
66
95
|
*/
|
|
67
96
|
const SERVE_MAX_TENANT_RESOURCES = 10_000;
|
|
68
|
-
/** How often `serve` maintains each tenant with data (
|
|
97
|
+
/** How often `serve` maintains each tenant with data (docs, Operations: at least daily). */
|
|
69
98
|
const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
70
99
|
/** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
|
|
71
100
|
const MAINTAIN_MAX_PASSES = 100;
|
|
@@ -95,6 +124,9 @@ const SERVED_OPERATIONS = new Set([
|
|
|
95
124
|
'slotlock_list_events',
|
|
96
125
|
'slotlock_update_event',
|
|
97
126
|
'slotlock_delete_event',
|
|
127
|
+
'slotlock_confirm_slot',
|
|
128
|
+
'slotlock_reschedule',
|
|
129
|
+
'slotlock_cancel',
|
|
98
130
|
]);
|
|
99
131
|
const HEALTH_QUERY_TIMEOUT_MS = 2_000;
|
|
100
132
|
/** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
|
|
@@ -203,6 +235,39 @@ function readSharedLimits(env) {
|
|
|
203
235
|
}
|
|
204
236
|
return value === 'on';
|
|
205
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
|
+
}
|
|
206
271
|
function readRestApi(env) {
|
|
207
272
|
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
208
273
|
if (value !== 'on' && value !== 'off') {
|
|
@@ -234,14 +299,39 @@ export function readSlotlockServeConfig(env) {
|
|
|
234
299
|
restApi: readRestApi(env),
|
|
235
300
|
webhooks: readWebhooks(env),
|
|
236
301
|
sharedLimits: readSharedLimits(env),
|
|
302
|
+
usage: readUsageMetering(env),
|
|
303
|
+
quotas: false,
|
|
237
304
|
limits: readLimits(env),
|
|
238
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
|
+
}
|
|
239
317
|
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
240
318
|
if (trustedProxyHeader !== undefined)
|
|
241
319
|
config.trustedProxyHeader = trustedProxyHeader;
|
|
242
320
|
const trustedProxySecret = readTrustedProxySecret(env, trustedProxyHeader);
|
|
243
321
|
if (trustedProxySecret !== undefined)
|
|
244
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;
|
|
245
335
|
if (authToken !== undefined)
|
|
246
336
|
config.authToken = authToken;
|
|
247
337
|
const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
|
|
@@ -252,6 +342,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
252
342
|
env.SLOTLOCK_SESSION_SECRET?.trim(),
|
|
253
343
|
env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
|
|
254
344
|
env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
|
|
345
|
+
...(env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.split(',').map((part) => part.trim()) ?? []),
|
|
255
346
|
];
|
|
256
347
|
if (others.includes(metricsToken)) {
|
|
257
348
|
throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
|
|
@@ -326,6 +417,14 @@ function secretsIn(env) {
|
|
|
326
417
|
if (value)
|
|
327
418
|
secrets.push(value);
|
|
328
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
|
+
}
|
|
329
428
|
for (const name of ['DATABASE_URL', 'SLOTLOCK_MIGRATE_DATABASE_URL']) {
|
|
330
429
|
const value = env[name]?.trim();
|
|
331
430
|
if (!value)
|
|
@@ -513,13 +612,19 @@ function serveMetrics(target, metrics, path, token) {
|
|
|
513
612
|
* SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
|
|
514
613
|
*/
|
|
515
614
|
export function slotlockServeAllowsTenant(config, tenantRef) {
|
|
516
|
-
|
|
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).
|
|
517
619
|
if (orgId !== undefined)
|
|
518
|
-
return config.organizations?.includes(orgId) === true;
|
|
620
|
+
return config.organizations === 'enabled' || config.organizations?.includes(orgId) === true;
|
|
519
621
|
const users = config.allowedUsers;
|
|
520
622
|
if (users === undefined || users === '*')
|
|
521
623
|
return true;
|
|
522
|
-
|
|
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];
|
|
523
628
|
return githubId === undefined || users.includes(githubId);
|
|
524
629
|
}
|
|
525
630
|
function serveTenantLimits(caps) {
|
|
@@ -639,10 +744,41 @@ export async function startSlotlockServer(config, log) {
|
|
|
639
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' });
|
|
640
745
|
}
|
|
641
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;
|
|
642
776
|
const { caps } = config.limits;
|
|
643
777
|
const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
|
|
644
778
|
const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
|
|
645
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);
|
|
646
782
|
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
647
783
|
maxConcurrentLookups: caps.key_lookups,
|
|
648
784
|
maxWaitingLookups: caps.key_lookup_queue,
|
|
@@ -660,12 +796,28 @@ export async function startSlotlockServer(config, log) {
|
|
|
660
796
|
let maintenance;
|
|
661
797
|
let webhooks;
|
|
662
798
|
const basePath = publicUrl.pathname.replace(/\/$/, '');
|
|
663
|
-
|
|
799
|
+
// Counted for the scrape (SLOTLOCK_METRICS_TOKEN), an OpenTelemetry collector, or both.
|
|
800
|
+
const metrics = config.metricsToken === undefined && config.otlp?.metricsUrl === undefined
|
|
664
801
|
? undefined
|
|
665
802
|
: createSlotlockMetrics({
|
|
666
803
|
version: slotlockPackageVersion(),
|
|
667
804
|
...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
|
|
668
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;
|
|
669
821
|
const limits = createSlotlockRequestLimits({
|
|
670
822
|
limits: config.limits,
|
|
671
823
|
...(config.sharedLimits
|
|
@@ -688,14 +840,27 @@ export async function startSlotlockServer(config, log) {
|
|
|
688
840
|
? { trustedProxySecret: config.trustedProxySecret }
|
|
689
841
|
: {}),
|
|
690
842
|
basePath,
|
|
843
|
+
...(quotas ? { quotas } : {}),
|
|
691
844
|
signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
|
|
692
845
|
// The token is compared in constant time first; only then is a key looked up. A person the
|
|
693
846
|
// dashboard no longer allows loses their keys with their session.
|
|
694
847
|
authenticate: async (request) => {
|
|
695
|
-
const principal = (await authenticateToken?.(request)) ??
|
|
848
|
+
const principal = (await authenticateToken?.(request)) ??
|
|
849
|
+
(await authenticatePartner(request)) ??
|
|
850
|
+
(await authenticateKey(request));
|
|
696
851
|
if (!principal || !slotlockServeAllowsTenant(config, principal.tenantRef))
|
|
697
852
|
return null;
|
|
698
|
-
|
|
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)
|
|
859
|
+
return null;
|
|
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));
|
|
699
864
|
return principal;
|
|
700
865
|
},
|
|
701
866
|
// Counts and pseudonyms only: never an address, a credential or a tenant.
|
|
@@ -711,6 +876,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
711
876
|
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
712
877
|
});
|
|
713
878
|
const agentServer = createSlotlockAgentServer({
|
|
879
|
+
...(usage ? { onUsage: usage.count } : {}),
|
|
714
880
|
publicBaseUrl: config.publicUrl,
|
|
715
881
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
716
882
|
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
@@ -722,6 +888,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
722
888
|
authorize: async ({ principal, operation, input }) => {
|
|
723
889
|
if (!authorizeSlotlockServeOperation({ principal, operation }))
|
|
724
890
|
return false;
|
|
891
|
+
await limits.quota({ principal, operation });
|
|
725
892
|
await limits.admit({ principal, operation, input });
|
|
726
893
|
return true;
|
|
727
894
|
},
|
|
@@ -740,9 +907,13 @@ export async function startSlotlockServer(config, log) {
|
|
|
740
907
|
? { ready: true, checks: ['database'] }
|
|
741
908
|
: { ready: false, checks: ['database_unreachable'] };
|
|
742
909
|
},
|
|
910
|
+
...(tracer ? { onCallTrace: (event, trace) => tracer.record(event, trace.parentSpanId) } : {}),
|
|
743
911
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
744
912
|
onEvent: (event) => {
|
|
745
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);
|
|
746
917
|
const { type, ...fields } = event;
|
|
747
918
|
log.info(type, snakeCaseKeys(fields));
|
|
748
919
|
},
|
|
@@ -756,10 +927,19 @@ export async function startSlotlockServer(config, log) {
|
|
|
756
927
|
application: {
|
|
757
928
|
backend: createSlotlockStoreApplicationBackend(store, {
|
|
758
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
|
+
}),
|
|
759
936
|
}),
|
|
760
937
|
authorize: async ({ principal, operation, input }) => {
|
|
761
|
-
|
|
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)
|
|
762
941
|
return false;
|
|
942
|
+
await limits.quota({ principal, operation });
|
|
763
943
|
await limits.admit({ principal, operation, input });
|
|
764
944
|
return true;
|
|
765
945
|
},
|
|
@@ -786,27 +966,46 @@ export async function startSlotlockServer(config, log) {
|
|
|
786
966
|
},
|
|
787
967
|
sessionSecret: config.dashboard.sessionSecret,
|
|
788
968
|
allowedUsers: config.dashboard.allowedUsers,
|
|
969
|
+
...(config.dashboard.google ? { google: config.dashboard.google } : {}),
|
|
789
970
|
...(config.dashboard.name ? { name: config.dashboard.name } : {}),
|
|
971
|
+
...(config.dashboard.termsUrl ? { termsUrl: config.dashboard.termsUrl } : {}),
|
|
972
|
+
...(config.dashboard.privacyUrl ? { privacyUrl: config.dashboard.privacyUrl } : {}),
|
|
790
973
|
...(config.dashboard.organizations
|
|
791
974
|
? { organizations: config.dashboard.organizations }
|
|
792
975
|
: {}),
|
|
976
|
+
...(config.dashboard.organizations === 'enabled' ? { teams: createSlotlockDashboardTeams(sql) } : {}),
|
|
793
977
|
keys: createSlotlockApiKeyStore(sql),
|
|
794
|
-
resources: createSlotlockDashboardResources(store),
|
|
978
|
+
resources: createSlotlockDashboardResources(store, { agentBackend }),
|
|
795
979
|
state: createSlotlockDashboardState(sql),
|
|
796
980
|
availability: config.availability,
|
|
981
|
+
...(config.usage ? { usage: true } : {}),
|
|
982
|
+
...(config.quotas ? { quotas: true } : {}),
|
|
797
983
|
...(config.dashboard.assistantSecret !== undefined
|
|
798
984
|
? {
|
|
799
985
|
// The assistant's reads and approved writes are agent calls: the same backend,
|
|
800
986
|
// operations, rate limits and series-write slots as an API key's.
|
|
801
987
|
assistant: {
|
|
802
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
|
+
: {}),
|
|
803
998
|
...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
|
|
804
999
|
backend: agentBackend,
|
|
805
1000
|
consumeRateLimit: limits.consumeRateLimit,
|
|
806
1001
|
// Checks only: a proposal holds nothing. The series-write slot and the write
|
|
807
1002
|
// token are taken when an approved change runs (admit, consumeRateLimit).
|
|
808
1003
|
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
809
|
-
|
|
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
|
+
},
|
|
810
1009
|
},
|
|
811
1010
|
}
|
|
812
1011
|
: {}),
|
|
@@ -837,6 +1036,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
837
1036
|
discover: () => pageTenants((after) => sql `
|
|
838
1037
|
SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
|
|
839
1038
|
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
1039
|
+
...(config.usage ? { usage: true } : {}),
|
|
840
1040
|
// Counts only, never the tenant.
|
|
841
1041
|
onPass: (result) => {
|
|
842
1042
|
metrics?.maintenance(result);
|
|
@@ -861,10 +1061,11 @@ export async function startSlotlockServer(config, log) {
|
|
|
861
1061
|
}),
|
|
862
1062
|
},
|
|
863
1063
|
}),
|
|
864
|
-
// Counts only, never the tenant or a URL.
|
|
865
|
-
onPass: (
|
|
1064
|
+
// Counts only, never the tenant or a URL; the tenant's own count goes to its usage.
|
|
1065
|
+
onPass: (tenantRef, result) => {
|
|
866
1066
|
metrics?.webhooks(result);
|
|
867
1067
|
log.info('webhooks', { ...result });
|
|
1068
|
+
usage?.add({ tenantRef, metric: 'webhook_deliveries', origin: 'webhooks', count: result.delivered });
|
|
868
1069
|
},
|
|
869
1070
|
onError: (error) => log.error('webhooks_failed', describeError(error)),
|
|
870
1071
|
});
|
|
@@ -881,6 +1082,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
881
1082
|
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
882
1083
|
webhooks: config.webhooks,
|
|
883
1084
|
shared_limits: config.sharedLimits,
|
|
1085
|
+
usage: config.usage,
|
|
1086
|
+
quotas: config.quotas,
|
|
884
1087
|
tzdata: process.versions.tz ?? null,
|
|
885
1088
|
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
886
1089
|
limits: {
|
|
@@ -897,6 +1100,10 @@ export async function startSlotlockServer(config, log) {
|
|
|
897
1100
|
async close() {
|
|
898
1101
|
const result = await listener.close();
|
|
899
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();
|
|
900
1107
|
await maintenance?.close();
|
|
901
1108
|
await webhooks?.close();
|
|
902
1109
|
await sql.end({ timeout: 5 });
|
|
@@ -917,6 +1124,94 @@ function resourceLine(resource) {
|
|
|
917
1124
|
})}\n`;
|
|
918
1125
|
}
|
|
919
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
|
+
}
|
|
920
1215
|
async function addResource(config, externalRef, timezone) {
|
|
921
1216
|
const sql = connect(config.databaseUrl, 1);
|
|
922
1217
|
try {
|
|
@@ -1089,6 +1384,229 @@ async function changeResource(config, out, name, action) {
|
|
|
1089
1384
|
await sql.end({ timeout: 5 });
|
|
1090
1385
|
}
|
|
1091
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
|
+
}
|
|
1092
1610
|
/**
|
|
1093
1611
|
* `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
|
|
1094
1612
|
* are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
|
|
@@ -1125,6 +1643,52 @@ async function runTenantCommand(config, out, action, reason) {
|
|
|
1125
1643
|
await sql.end({ timeout: 5 });
|
|
1126
1644
|
}
|
|
1127
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
|
+
}
|
|
1128
1692
|
/** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
|
|
1129
1693
|
function apiKeyLine(apiKey, key) {
|
|
1130
1694
|
return `${JSON.stringify({
|
|
@@ -1137,9 +1701,55 @@ function apiKeyLine(apiKey, key) {
|
|
|
1137
1701
|
expires_at: apiKey.expiresAt?.toISOString() ?? null,
|
|
1138
1702
|
last_used_at: apiKey.lastUsedAt?.toISOString() ?? null,
|
|
1139
1703
|
revoked_at: apiKey.revokedAt?.toISOString() ?? null,
|
|
1704
|
+
...(apiKey.resourceScope ? { resource_scope: apiKey.resourceScope } : {}),
|
|
1705
|
+
...(apiKey.limits ? { limits: apiKeyLimitsJson(apiKey.limits) } : {}),
|
|
1140
1706
|
...(key !== undefined ? { key } : {}),
|
|
1141
1707
|
})}\n`;
|
|
1142
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
|
+
}
|
|
1143
1753
|
/** `--scope`: read, write and admin, comma-separated; read,write unless given. */
|
|
1144
1754
|
function parseKeyScopes(value) {
|
|
1145
1755
|
if (value === undefined)
|
|
@@ -1169,6 +1779,7 @@ async function maintainTenant(config, caps) {
|
|
|
1169
1779
|
staleSources: 0,
|
|
1170
1780
|
noShows: 0,
|
|
1171
1781
|
waitlistOffers: 0,
|
|
1782
|
+
approvalsExpired: 0,
|
|
1172
1783
|
capped: true,
|
|
1173
1784
|
passes: 0,
|
|
1174
1785
|
};
|
|
@@ -1183,6 +1794,7 @@ async function maintainTenant(config, caps) {
|
|
|
1183
1794
|
sum.staleSources += pass.staleSources;
|
|
1184
1795
|
sum.noShows += pass.noShows;
|
|
1185
1796
|
sum.waitlistOffers += pass.waitlistOffers;
|
|
1797
|
+
sum.approvalsExpired += pass.approvalsExpired;
|
|
1186
1798
|
// A capped pass that got nowhere met only series it cannot extend. While its roll stopped
|
|
1187
1799
|
// before the series it had put back, the next pass meets new ones (and perhaps a healthy
|
|
1188
1800
|
// one); once it reached them, another pass would meet them again.
|
|
@@ -1253,6 +1865,10 @@ Commands:
|
|
|
1253
1865
|
resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
|
|
1254
1866
|
no longer listed; what is on it stays
|
|
1255
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
|
|
1256
1872
|
resource delete <ref> Delete a resource and its history; refused while a booking, hold
|
|
1257
1873
|
or event on it has not ended
|
|
1258
1874
|
hours show [--resource <ref>]
|
|
@@ -1267,13 +1883,63 @@ Commands:
|
|
|
1267
1883
|
rules clear --resource <ref>
|
|
1268
1884
|
Remove the resource's booking rules
|
|
1269
1885
|
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
1886
|
+
[--locations <ref,...>] [--groups <ref,...>]
|
|
1270
1887
|
Create an API key for the tenant (default scope read,write, no
|
|
1271
1888
|
expiry; admin adds the application API) and print it once; only its
|
|
1272
|
-
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
|
|
1273
1900
|
key list Print the tenant's API keys, one JSON object per line
|
|
1274
1901
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
1275
1902
|
working at once, and the key keeps its id and what it booked
|
|
1276
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
|
|
1277
1943
|
tenant status Print whether the tenant is suspended
|
|
1278
1944
|
tenant suspend [--reason <text>]
|
|
1279
1945
|
Suspend the tenant: it may read, and writes nothing; its keys get
|
|
@@ -1282,7 +1948,13 @@ Commands:
|
|
|
1282
1948
|
tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
|
|
1283
1949
|
tenant erase --confirm <tenant>
|
|
1284
1950
|
Delete everything the tenant has, its API keys included, for good;
|
|
1285
|
-
--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)
|
|
1286
1958
|
healthcheck Exit 0 when the local server reports ready
|
|
1287
1959
|
|
|
1288
1960
|
Options:
|
|
@@ -1312,6 +1984,16 @@ Environment:
|
|
|
1312
1984
|
SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
|
|
1313
1985
|
and retired answers (never under 30) (1-3650, default 30;
|
|
1314
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
|
|
1315
1997
|
SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
|
|
1316
1998
|
database, so N servers allow a tenant its rate once; off (default):
|
|
1317
1999
|
each server keeps its own
|
|
@@ -1342,6 +2024,14 @@ Environment:
|
|
|
1342
2024
|
SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,
|
|
1343
2025
|
32+ random characters: without it there, the field above is
|
|
1344
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)
|
|
1345
2035
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
1346
2036
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
1347
2037
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
@@ -1349,13 +2039,24 @@ Environment:
|
|
|
1349
2039
|
SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
|
|
1350
2040
|
model API key with it, 32+ random characters (dashboard; off
|
|
1351
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
|
|
1352
2045
|
SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
|
|
1353
2046
|
resolves to, a loopback, private, link-local, CGNAT, unique-local
|
|
1354
2047
|
or metadata address; on: allow them, for a self-hosted model
|
|
1355
2048
|
(Ollama, vLLM) on this machine or network
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
|
1358
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
|
|
1359
2060
|
HOST Listen address (default 127.0.0.1)
|
|
1360
2061
|
PORT Listen port (default 8080)
|
|
1361
2062
|
|
|
@@ -1396,7 +2097,12 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1396
2097
|
command === 'key' ||
|
|
1397
2098
|
command === 'hours' ||
|
|
1398
2099
|
command === 'rules' ||
|
|
1399
|
-
command === 'tenant'
|
|
2100
|
+
command === 'tenant' ||
|
|
2101
|
+
command === 'plan' ||
|
|
2102
|
+
command === 'partner' ||
|
|
2103
|
+
command === 'overage' ||
|
|
2104
|
+
command === 'billing' ||
|
|
2105
|
+
command === 'sandbox'
|
|
1400
2106
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
1401
2107
|
: command;
|
|
1402
2108
|
const expectedOperands = {
|
|
@@ -1407,21 +2113,40 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1407
2113
|
'resource add': 2,
|
|
1408
2114
|
'resource list': 1,
|
|
1409
2115
|
'resource archive': 2,
|
|
2116
|
+
'resource parent': 3,
|
|
1410
2117
|
'resource restore': 2,
|
|
1411
2118
|
'resource delete': 2,
|
|
1412
2119
|
'tenant status': 1,
|
|
1413
2120
|
'tenant suspend': 1,
|
|
1414
2121
|
'tenant unsuspend': 1,
|
|
1415
2122
|
'tenant erase': 1,
|
|
2123
|
+
'plan status': 1,
|
|
2124
|
+
'plan default': 2,
|
|
1416
2125
|
'key create': 2,
|
|
1417
2126
|
'key list': 1,
|
|
1418
2127
|
'key rotate': 2,
|
|
1419
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,
|
|
1420
2134
|
'hours show': 1,
|
|
1421
2135
|
'hours set': 2,
|
|
1422
2136
|
'hours clear': 1,
|
|
1423
2137
|
'rules set': 2,
|
|
1424
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,
|
|
1425
2150
|
};
|
|
1426
2151
|
const expected = expectedOperands[subcommand];
|
|
1427
2152
|
if (expected === undefined)
|
|
@@ -1438,12 +2163,45 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1438
2163
|
return usage(`--${option} applies only to key create`);
|
|
1439
2164
|
}
|
|
1440
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
|
+
}
|
|
1441
2181
|
if (values.reason !== undefined && subcommand !== 'tenant suspend') {
|
|
1442
2182
|
return usage('--reason applies only to tenant suspend');
|
|
1443
2183
|
}
|
|
1444
2184
|
if (values.confirm !== undefined && subcommand !== 'tenant erase') {
|
|
1445
2185
|
return usage('--confirm applies only to tenant erase');
|
|
1446
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
|
+
}
|
|
1447
2205
|
if (subcommand === 'tenant erase' && values.confirm === undefined) {
|
|
1448
2206
|
return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
|
|
1449
2207
|
}
|
|
@@ -1491,15 +2249,36 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1491
2249
|
}
|
|
1492
2250
|
let keyScopes = [];
|
|
1493
2251
|
let keyLifetimeDays;
|
|
2252
|
+
let keyResourceScope;
|
|
2253
|
+
let keyLimits = {};
|
|
1494
2254
|
if (subcommand === 'key create') {
|
|
1495
2255
|
try {
|
|
1496
2256
|
keyScopes = parseKeyScopes(values.scope);
|
|
1497
2257
|
keyLifetimeDays = parseKeyLifetime(values['expires-in-days']);
|
|
2258
|
+
keyResourceScope = parseKeyResourceScope(values.locations, values.groups);
|
|
2259
|
+
}
|
|
2260
|
+
catch (error) {
|
|
2261
|
+
return usage(error.message);
|
|
2262
|
+
}
|
|
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);
|
|
1498
2274
|
}
|
|
1499
2275
|
catch (error) {
|
|
1500
2276
|
return usage(error.message);
|
|
1501
2277
|
}
|
|
1502
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
|
+
}
|
|
1503
2282
|
const log = createLogger(io);
|
|
1504
2283
|
try {
|
|
1505
2284
|
switch (subcommand) {
|
|
@@ -1528,6 +2307,11 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1528
2307
|
io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1529
2308
|
return 0;
|
|
1530
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);
|
|
1531
2315
|
case 'resource add': {
|
|
1532
2316
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1533
2317
|
const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
|
|
@@ -1537,6 +2321,78 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1537
2321
|
case 'resource list':
|
|
1538
2322
|
await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
|
|
1539
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
|
+
}
|
|
1540
2396
|
case 'resource archive':
|
|
1541
2397
|
case 'resource restore':
|
|
1542
2398
|
case 'resource delete': {
|
|
@@ -1572,6 +2428,53 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1572
2428
|
if (code === 'invalid_suspension_reason') {
|
|
1573
2429
|
return usage('--reason must be 1-500 characters without control characters');
|
|
1574
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
|
+
}
|
|
1575
2478
|
throw error;
|
|
1576
2479
|
}
|
|
1577
2480
|
return 0;
|
|
@@ -1607,6 +2510,8 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1607
2510
|
name: operands[1],
|
|
1608
2511
|
scopes: keyScopes,
|
|
1609
2512
|
createdBy: CLI_KEY_CREATOR,
|
|
2513
|
+
...(keyResourceScope ? { resourceScope: keyResourceScope } : {}),
|
|
2514
|
+
...(Object.keys(keyLimits).length > 0 ? { limits: keyLimits } : {}),
|
|
1610
2515
|
...(keyLifetimeDays !== undefined
|
|
1611
2516
|
? { expiresAt: new Date(Date.now() + keyLifetimeDays * DAY_MS) }
|
|
1612
2517
|
: {}),
|
|
@@ -1633,6 +2538,17 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1633
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');
|
|
1634
2539
|
return 0;
|
|
1635
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
|
+
}
|
|
1636
2552
|
case 'key revoke': {
|
|
1637
2553
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1638
2554
|
const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
|
|
@@ -1666,10 +2582,22 @@ function parseCliArguments(argv) {
|
|
|
1666
2582
|
timezone: { type: 'string' },
|
|
1667
2583
|
scope: { type: 'string' },
|
|
1668
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' },
|
|
1669
2596
|
resource: { type: 'string' },
|
|
1670
2597
|
all: { type: 'boolean' },
|
|
1671
2598
|
reason: { type: 'string' },
|
|
1672
2599
|
confirm: { type: 'string' },
|
|
2600
|
+
from: { type: 'string' },
|
|
1673
2601
|
help: { type: 'boolean', short: 'h' },
|
|
1674
2602
|
version: { type: 'boolean', short: 'v' },
|
|
1675
2603
|
},
|