slotlock 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -6
  3. package/SECURITY.md +15 -4
  4. package/agent-server.js +1 -5
  5. package/agent-store-backend.d.ts +5 -6
  6. package/agent-store-backend.js +41 -67
  7. package/api-keys.js +1 -1
  8. package/application-api.d.ts +3 -3
  9. package/application-api.js +16 -24
  10. package/assistant.d.ts +18 -27
  11. package/assistant.js +36 -53
  12. package/booking-rules.d.ts +30 -41
  13. package/booking-rules.js +17 -25
  14. package/change-watch.d.ts +1 -0
  15. package/change-watch.js +1 -1
  16. package/cli.js +3 -4
  17. package/config.d.ts +17 -2
  18. package/config.js +44 -6
  19. package/dashboard.js +14 -1
  20. package/ddl.d.ts +20 -27
  21. package/ddl.js +166 -108
  22. package/engine.d.ts +6 -9
  23. package/engine.js +5 -9
  24. package/event-feed.d.ts +23 -31
  25. package/event-feed.js +30 -45
  26. package/event-schema.js +6 -9
  27. package/hooks.d.ts +2 -4
  28. package/hooks.js +2 -4
  29. package/ical-worker-thread.js +3 -4
  30. package/ical-worker.d.ts +8 -10
  31. package/ical-worker.js +7 -9
  32. package/interop.js +1 -4
  33. package/maintenance.d.ts +14 -10
  34. package/maintenance.js +43 -26
  35. package/mcp-app.d.ts +7 -9
  36. package/mcp-app.js +7 -9
  37. package/mcp-modern.d.ts +14 -16
  38. package/mcp-modern.js +23 -27
  39. package/metrics.js +0 -1
  40. package/node-server.d.ts +9 -9
  41. package/node-server.js +25 -34
  42. package/package-version.js +2 -4
  43. package/package.json +1 -1
  44. package/rate-limit.d.ts +6 -1
  45. package/rate-limit.js +24 -8
  46. package/rest-api.d.ts +13 -14
  47. package/rest-api.js +11 -12
  48. package/rules.d.ts +6 -7
  49. package/rules.js +30 -42
  50. package/self-host.d.ts +16 -2
  51. package/self-host.js +42 -21
  52. package/slotlock.manifest.json +1 -1
  53. package/store.d.ts +109 -161
  54. package/store.js +253 -371
  55. package/sync.d.ts +44 -59
  56. package/sync.js +325 -469
  57. package/timezone.d.ts +25 -33
  58. package/timezone.js +37 -52
  59. package/types.d.ts +35 -56
  60. package/webhooks.d.ts +22 -25
  61. package/webhooks.js +35 -40
  62. package/weekly-hours.d.ts +3 -0
  63. package/weekly-hours.js +1 -1
package/self-host.js CHANGED
@@ -11,7 +11,7 @@ import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent
11
11
  import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, } from './agent-store-backend.js';
12
12
  import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
13
13
  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';
14
+ import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readDashboardUsers, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, readTrustedProxySecret, slotlockKeySubscriptions, } from './config.js';
15
15
  import { parseBookingRules } from './booking-rules.js';
16
16
  import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
17
17
  import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
@@ -40,6 +40,7 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
40
40
  'SLOTLOCK_SHARED_LIMITS',
41
41
  'SLOTLOCK_LIMITS',
42
42
  'SLOTLOCK_TRUSTED_PROXY_HEADER',
43
+ 'SLOTLOCK_TRUSTED_PROXY_SECRET',
43
44
  'SLOTLOCK_GITHUB_CLIENT_ID',
44
45
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
45
46
  'SLOTLOCK_SESSION_SECRET',
@@ -64,7 +65,7 @@ const DAY_MS = 86_400_000;
64
65
  * every GitHub user gives each a tenant, so the cap keeps one from filling the database.
65
66
  */
66
67
  const SERVE_MAX_TENANT_RESOURCES = 10_000;
67
- /** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
68
+ /** How often `serve` maintains each tenant with data (README, Operations: at least daily). */
68
69
  const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
69
70
  /** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
70
71
  const MAINTAIN_MAX_PASSES = 100;
@@ -238,6 +239,9 @@ export function readSlotlockServeConfig(env) {
238
239
  const trustedProxyHeader = readTrustedProxyHeader(env);
239
240
  if (trustedProxyHeader !== undefined)
240
241
  config.trustedProxyHeader = trustedProxyHeader;
242
+ const trustedProxySecret = readTrustedProxySecret(env, trustedProxyHeader);
243
+ if (trustedProxySecret !== undefined)
244
+ config.trustedProxySecret = trustedProxySecret;
241
245
  if (authToken !== undefined)
242
246
  config.authToken = authToken;
243
247
  const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
@@ -260,6 +264,9 @@ export function readSlotlockServeConfig(env) {
260
264
  const organizations = readDashboardOrganizations(env);
261
265
  if (organizations !== undefined)
262
266
  config.organizations = organizations;
267
+ const allowedUsers = dashboard?.allowedUsers ?? readDashboardUsers(env);
268
+ if (allowedUsers !== undefined)
269
+ config.allowedUsers = allowedUsers;
263
270
  if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
264
271
  config.confirmationSecret = confirmationSecret;
265
272
  }
@@ -313,6 +320,7 @@ function secretsIn(env) {
313
320
  'SLOTLOCK_SESSION_SECRET',
314
321
  'SLOTLOCK_ASSISTANT_SECRET',
315
322
  'SLOTLOCK_METRICS_TOKEN',
323
+ 'SLOTLOCK_TRUSTED_PROXY_SECRET',
316
324
  ]) {
317
325
  const value = env[name]?.trim();
318
326
  if (value)
@@ -499,16 +507,16 @@ function serveMetrics(target, metrics, path, token) {
499
507
  };
500
508
  }
501
509
  /**
502
- * Whether the dashboard's allowlists let a tenant act: a `github-org:<id>` tenant only while
503
- * SLOTLOCK_DASHBOARD_ORGS lists that organization, wherever the dashboard runs; otherwise every
504
- * tenant when there is no dashboard or it allows everyone, and a `github:<id>` tenant only while
505
- * that id is listed.
510
+ * Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
511
+ * `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
512
+ * `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
513
+ * SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
506
514
  */
507
- function dashboardAllows(config, tenantRef) {
515
+ export function slotlockServeAllowsTenant(config, tenantRef) {
508
516
  const orgId = /^github-org:(\d+)$/.exec(tenantRef)?.[1];
509
517
  if (orgId !== undefined)
510
518
  return config.organizations?.includes(orgId) === true;
511
- const users = config.dashboard?.allowedUsers;
519
+ const users = config.allowedUsers;
512
520
  if (users === undefined || users === '*')
513
521
  return true;
514
522
  const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
@@ -550,6 +558,16 @@ export function createSlotlockMaintenanceStore(sql, caps, options = {}) {
550
558
  maxHeldHoursPerHolder: caps.holder_held_hours,
551
559
  });
552
560
  }
561
+ /** Every tenant a paged SECURITY DEFINER list (1,000 names a page, after the last) returns. */
562
+ async function pageTenants(page) {
563
+ const tenants = [];
564
+ for (;;) {
565
+ const rows = await page(tenants.at(-1) ?? null);
566
+ tenants.push(...rows.map(({ tenant }) => tenant));
567
+ if (rows.length < 1_000)
568
+ return tenants;
569
+ }
570
+ }
553
571
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
554
572
  export async function startSlotlockServer(config, log) {
555
573
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -666,13 +684,16 @@ export async function startSlotlockServer(config, log) {
666
684
  ...(config.trustedProxyHeader !== undefined
667
685
  ? { trustedProxyHeader: config.trustedProxyHeader }
668
686
  : {}),
687
+ ...(config.trustedProxySecret !== undefined
688
+ ? { trustedProxySecret: config.trustedProxySecret }
689
+ : {}),
669
690
  basePath,
670
691
  signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
671
692
  // The token is compared in constant time first; only then is a key looked up. A person the
672
693
  // dashboard no longer allows loses their keys with their session.
673
694
  authenticate: async (request) => {
674
695
  const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
675
- if (!principal || !dashboardAllows(config, principal.tenantRef))
696
+ if (!principal || !slotlockServeAllowsTenant(config, principal.tenantRef))
676
697
  return null;
677
698
  maintenance?.touch(principal.tenantRef);
678
699
  return principal;
@@ -811,6 +832,10 @@ export async function startSlotlockServer(config, log) {
811
832
  store: createSlotlockMaintenanceStore(sql, caps, retention),
812
833
  sql,
813
834
  tenants: [config.tenantRef],
835
+ // Every tenant with data, from the database: one whose keys have not called since this server
836
+ // started is maintained too. Names only; each pass runs in its tenant's own context.
837
+ discover: () => pageTenants((after) => sql `
838
+ SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
814
839
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
815
840
  // Counts only, never the tenant.
816
841
  onPass: (result) => {
@@ -824,16 +849,8 @@ export async function startSlotlockServer(config, log) {
824
849
  const send = createSlotlockWebhookSender({ allowPrivateNetworks: config.webhooks === 'private' });
825
850
  webhooks = startSlotlockWebhookDelivery({
826
851
  // The tenants with a webhook, from the database: a restarted server finds them all.
827
- tenants: async () => {
828
- const tenants = [];
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
- },
852
+ tenants: () => pageTenants((after) => sql `
853
+ SELECT slotlock.webhook_tenants(${after}::text) AS tenant`),
837
854
  deliver: (tenantRef) => deliverSlotlockWebhooks({
838
855
  tenantRef,
839
856
  send,
@@ -873,6 +890,7 @@ export async function startSlotlockServer(config, log) {
873
890
  key_subscriptions: slotlockKeySubscriptions(caps),
874
891
  },
875
892
  client_address: config.trustedProxyHeader ?? 'socket',
893
+ client_address_proof: config.trustedProxySecret !== undefined,
876
894
  });
877
895
  return {
878
896
  address,
@@ -1226,7 +1244,7 @@ Commands:
1226
1244
  migrate Apply the schema and row-level security, then grant the serving
1227
1245
  role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
1228
1246
  serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
1229
- maintains each tenant it serves every hour
1247
+ maintains every tenant with data every hour
1230
1248
  maintain Run maintenance for the tenant now: extend recurring events to the
1231
1249
  rolling horizon and prune past the retention window (for cron)
1232
1250
  resource add <ref> [--timezone <IANA zone>]
@@ -1321,6 +1339,9 @@ Environment:
1321
1339
  SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
1322
1340
  such as CF-Connecting-IP (default: the connection's address);
1323
1341
  set it only when every request comes through that proxy
1342
+ SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,
1343
+ 32+ random characters: without it there, the field above is
1344
+ ignored and the connection's address counts
1324
1345
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
1325
1346
  of a GitHub OAuth app with callback .../dashboard/callback
1326
1347
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -1332,7 +1353,7 @@ Environment:
1332
1353
  resolves to, a loopback, private, link-local, CGNAT, unique-local
1333
1354
  or metadata address; on: allow them, for a self-hosted model
1334
1355
  (Ollama, vLLM) on this machine or network
1335
- SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
1356
+ SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in and whose keys work
1336
1357
  SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
1337
1358
  SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
1338
1359
  HOST Listen address (default 127.0.0.1)
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.6.1",
6
+ "version": "0.6.3",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",