slotlock 0.1.0 → 0.2.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/self-host.d.ts CHANGED
@@ -33,7 +33,10 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
33
33
  availability: readonly WeeklyAvailabilityRule[];
34
34
  /** Set exactly when SLOTLOCK_GITHUB_CLIENT_ID is. */
35
35
  dashboard?: SlotlockServeDashboardConfig;
36
- /** Serve the REST API under /v1 and its OpenAPI document (SLOTLOCK_REST_API=on). */
36
+ /**
37
+ * Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
38
+ * (SLOTLOCK_REST_API: on unless off).
39
+ */
37
40
  restApi: boolean;
38
41
  }
39
42
  export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
@@ -84,7 +87,7 @@ export interface SlotlockRunningServer {
84
87
  }
85
88
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
86
89
  export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
87
- export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP and A2A over HTTP (--migrate runs migrate first)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n key create <name> [--scope read|write|read,write] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry) and print it once; only its SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,\n 32+ random characters (serve); API keys work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)\n SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on: also serve the tools as a REST API under /v1, described at\n /openapi.json, with the same keys and confirmation (default off)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
90
+ export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,\n 32+ random characters (serve); API keys work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)\n SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
88
91
  /**
89
92
  * Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
90
93
  * configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
package/self-host.js CHANGED
@@ -7,11 +7,13 @@ import { createHash, timingSafeEqual } from 'node:crypto';
7
7
  import { parseArgs } from 'node:util';
8
8
  import postgres from 'postgres';
9
9
  import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
10
- import { createSlotlockStoreAgentBackend } from './agent-store-backend.js';
11
- import { SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
10
+ import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
11
+ import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
12
12
  import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
13
13
  import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
14
- import { SLOTLOCK_DASHBOARD_FUNCTIONS } from './ddl.js';
14
+ import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
15
+ import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
16
+ import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
15
17
  import { createSlotlockNodeServer, } from './node-server.js';
16
18
  import { slotlockPackageVersion } from './package-version.js';
17
19
  import { createSlotlockStore } from './store.js';
@@ -43,6 +45,15 @@ const SELF_HOST_SUBJECT = 'self-host';
43
45
  /** Who `key create` records as a key's creator. */
44
46
  const CLI_KEY_CREATOR = 'cli';
45
47
  const DAY_MS = 86_400_000;
48
+ /**
49
+ * The most resources `serve` lets one tenant create through the application API. A dashboard open to
50
+ * every GitHub user gives each a tenant, so the cap keeps one from filling the database.
51
+ */
52
+ const SERVE_MAX_TENANT_RESOURCES = 10_000;
53
+ /** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
54
+ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
55
+ /** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
56
+ const MAINTAIN_MAX_PASSES = 100;
46
57
  const WRITE_OPERATIONS = [
47
58
  'slotlock_create_event',
48
59
  'slotlock_update_event',
@@ -138,7 +149,7 @@ export function readSlotlockDatabaseConfig(env) {
138
149
  return config;
139
150
  }
140
151
  function readRestApi(env) {
141
- const value = optional(env, 'SLOTLOCK_REST_API') ?? 'off';
152
+ const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
142
153
  if (value !== 'on' && value !== 'off') {
143
154
  throw new SlotlockConfigError('SLOTLOCK_REST_API must be on or off');
144
155
  }
@@ -272,6 +283,13 @@ function describeError(error) {
272
283
  }
273
284
  return detail;
274
285
  }
286
+ /** An agent server event's fields under the log's snake_case keys (`durationMs` → `duration_ms`). */
287
+ function snakeCaseKeys(fields) {
288
+ return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
289
+ key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
290
+ value,
291
+ ]));
292
+ }
275
293
  function connect(url, max) {
276
294
  return postgres(url, { max, onnotice: () => { }, connect_timeout: 10 });
277
295
  }
@@ -358,6 +376,20 @@ export async function startSlotlockServer(config, log) {
358
376
  if (!schema?.present) {
359
377
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
360
378
  }
379
+ // A deployment run statement by statement lifts forced row-level security for its data steps
380
+ // and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
381
+ // reads every tenant's rows: refuse to serve until then.
382
+ const lifted = await sql `
383
+ SELECT rel.relname AS name
384
+ FROM pg_catalog.pg_class rel
385
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
386
+ WHERE ns.nspname = 'slotlock'
387
+ AND rel.relname IN ${sql(SLOTLOCK_TENANT_TABLES)}
388
+ AND rel.relrowsecurity AND NOT rel.relforcerowsecurity
389
+ ORDER BY rel.relname`;
390
+ if (lifted.length > 0) {
391
+ throw Object.assign(new Error(`Row-level security is enabled but not forced on ${lifted.map(({ name }) => `slotlock.${name}`).join(', ')}: a deployment stopped part way. Run \`slotlock migrate\` (or \`slotlock serve --migrate\`) to put it back`), { code: 'row_security_lifted' });
392
+ }
361
393
  if (config.dashboard) {
362
394
  for (const signature of SLOTLOCK_DASHBOARD_FUNCTIONS) {
363
395
  const [granted] = await sql `
@@ -373,11 +405,15 @@ export async function startSlotlockServer(config, log) {
373
405
  const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql));
374
406
  const authenticateToken = config.authToken === undefined
375
407
  ? undefined
376
- : createSlotlockTokenAuthenticator(config.authToken, {
377
- subject: SELF_HOST_SUBJECT,
378
- tenantRef: config.tenantRef,
379
- scopes: SLOTLOCK_API_KEY_SCOPES,
380
- });
408
+ : // Read and write, never admin: this is the token the docs put in agents' configuration,
409
+ // and the application API's writes ask no person. An admin key is made for the purpose.
410
+ createSlotlockTokenAuthenticator(config.authToken, {
411
+ subject: SELF_HOST_SUBJECT,
412
+ tenantRef: config.tenantRef,
413
+ scopes: SLOTLOCK_API_KEY_DEFAULT_SCOPES,
414
+ });
415
+ // Set once the listener is up; the authenticator tells it which tenants this server serves.
416
+ let maintenance;
381
417
  const agentServer = createSlotlockAgentServer({
382
418
  publicBaseUrl: config.publicUrl,
383
419
  // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
@@ -389,18 +425,32 @@ export async function startSlotlockServer(config, log) {
389
425
  // dashboard no longer allows loses their keys with their session.
390
426
  authenticate: async (request) => {
391
427
  const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
392
- return principal && dashboardAllows(config, principal.tenantRef) ? principal : null;
428
+ if (!principal || !dashboardAllows(config, principal.tenantRef))
429
+ return null;
430
+ maintenance?.touch(principal.tenantRef);
431
+ return principal;
393
432
  },
394
433
  authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
395
434
  health: async () => (await databaseReachable(sql))
396
435
  ? { ready: true, checks: ['database'] }
397
436
  : { ready: false, checks: ['database_unreachable'] },
398
437
  // Outcomes and counts only: the server never reports arguments or identities.
399
- onEvent: ({ type, ...fields }) => log.info(type, fields),
438
+ onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
400
439
  // What an agent sees only as internal_error: the operation and the error's message and code,
401
440
  // never the arguments or the request, with every configured secret redacted.
402
441
  onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
403
- ...(config.restApi ? { rest: true } : {}),
442
+ ...(config.restApi
443
+ ? {
444
+ rest: true,
445
+ // Resources, hours, holds, reservations, coverage and maintenance: admin keys only.
446
+ application: {
447
+ backend: createSlotlockStoreApplicationBackend(store, {
448
+ maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
449
+ }),
450
+ authorize: async ({ principal }) => principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) === true,
451
+ },
452
+ }
453
+ : {}),
404
454
  ...(config.confirmationSecret !== undefined
405
455
  ? {
406
456
  confirmation: {
@@ -437,6 +487,15 @@ export async function startSlotlockServer(config, log) {
437
487
  onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
438
488
  });
439
489
  const address = await listener.listen({ host: config.host, port: config.port });
490
+ maintenance = startSlotlockMaintenance({
491
+ store,
492
+ sql,
493
+ tenants: [config.tenantRef],
494
+ intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
495
+ // Counts only, never the tenant.
496
+ onPass: (result) => log.info('maintenance', { ...result }),
497
+ onError: (error) => log.error('maintenance_failed', describeError(error)),
498
+ });
440
499
  log.info('listening', {
441
500
  address: address.origin,
442
501
  mcp: `${config.publicUrl}/mcp`,
@@ -446,11 +505,14 @@ export async function startSlotlockServer(config, log) {
446
505
  dashboard: dashboard ? `${config.publicUrl}/dashboard` : null,
447
506
  confirm_writes: config.confirmWrites,
448
507
  availability_rules: config.availability.length,
508
+ rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
509
+ maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
449
510
  });
450
511
  return {
451
512
  address,
452
513
  async close() {
453
514
  const result = await listener.close();
515
+ await maintenance?.close();
454
516
  await sql.end({ timeout: 5 });
455
517
  return result;
456
518
  },
@@ -579,17 +641,47 @@ function apiKeyLine(apiKey, key) {
579
641
  ...(key !== undefined ? { key } : {}),
580
642
  })}\n`;
581
643
  }
582
- /** `--scope`: read, write, or both comma-separated. */
644
+ /** `--scope`: read, write and admin, comma-separated; read,write unless given. */
583
645
  function parseKeyScopes(value) {
584
646
  if (value === undefined)
585
- return [...SLOTLOCK_API_KEY_SCOPES];
647
+ return [...SLOTLOCK_API_KEY_DEFAULT_SCOPES];
586
648
  const names = value.split(',').map((name) => name.trim());
587
649
  const scopes = SLOTLOCK_API_KEY_SCOPES.filter((scope) => names.includes(scope));
588
650
  if (names.length === 0 || names.some((name) => !scopes.includes(name))) {
589
- throw new Error('--scope must be read, write or read,write');
651
+ throw new Error('--scope must be a comma-separated list of read, write and admin');
590
652
  }
591
653
  return scopes;
592
654
  }
655
+ /**
656
+ * `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
657
+ * it never repeats what a server is doing. Null when another server holds the lock.
658
+ */
659
+ async function maintainTenant(config) {
660
+ const sql = connect(config.databaseUrl, 2);
661
+ try {
662
+ const store = createSlotlockStore(sql);
663
+ const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
664
+ const sum = { extended: 0, conflicts: 0, refused: 0, pruned: 0, capped: true, passes: 0 };
665
+ while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
666
+ const pass = await maintainSlotlockTenant(store, config.tenantRef);
667
+ sum.passes += 1;
668
+ sum.extended += pass.extended;
669
+ sum.conflicts += pass.conflicts;
670
+ sum.refused += pass.refused;
671
+ sum.pruned += pass.pruned;
672
+ // A capped pass that got nowhere met only series it cannot extend: another would too.
673
+ sum.capped = pass.capped;
674
+ if (pass.capped && pass.extended + pass.pruned === 0)
675
+ break;
676
+ }
677
+ return sum;
678
+ });
679
+ return total ?? null;
680
+ }
681
+ finally {
682
+ await sql.end({ timeout: 5 });
683
+ }
684
+ }
593
685
  /** `--expires-in-days`: a whole number of days up to the longest a key may live. */
594
686
  function parseKeyLifetime(value) {
595
687
  if (value === undefined)
@@ -632,7 +724,10 @@ export const SLOTLOCK_CLI_HELP = `Usage: slotlock <command> [options]
632
724
  Commands:
633
725
  migrate Apply the schema and row-level security, then grant the serving
634
726
  role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
635
- serve [--migrate] Serve MCP and A2A over HTTP (--migrate runs migrate first)
727
+ serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
728
+ maintains each tenant it serves every hour
729
+ maintain Run maintenance for the tenant now: extend recurring events to the
730
+ rolling horizon and prune past the retention window (for cron)
636
731
  resource add <ref> [--timezone <IANA zone>]
637
732
  Create a calendar resource (a car, room, person or machine)
638
733
  resource list Print the tenant's resources, one JSON object per line
@@ -642,9 +737,10 @@ Commands:
642
737
  Give one resource, or every one, weekly hours of its own; [] closes
643
738
  hours clear (--resource <ref> | --all)
644
739
  Hand one resource, or every one, back to the server's hours
645
- key create <name> [--scope read|write|read,write] [--expires-in-days <n>]
740
+ key create <name> [--scope read,write,admin] [--expires-in-days <n>]
646
741
  Create an API key for the tenant (default scope read,write, no
647
- expiry) and print it once; only its SHA-256 is stored
742
+ expiry; admin adds the application API) and print it once; only its
743
+ SHA-256 is stored
648
744
  key list Print the tenant's API keys, one JSON object per line
649
745
  key rotate <id> Give an active key a new secret and print it once; the old one stops
650
746
  working at once, and the key keeps its id and what it booked
@@ -666,8 +762,9 @@ Environment:
666
762
  SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
667
763
  SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
668
764
  SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
669
- SLOTLOCK_REST_API on: also serve the tools as a REST API under /v1, described at
670
- /openapi.json, with the same keys and confirmation (default off)
765
+ SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
766
+ the application API for admin keys, described at /openapi.json;
767
+ off: MCP and A2A only
671
768
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
672
769
  of a GitHub OAuth app with callback .../dashboard/callback
673
770
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -715,6 +812,7 @@ export async function runSlotlockCli(argv, io) {
715
812
  const expectedOperands = {
716
813
  migrate: 0,
717
814
  serve: 0,
815
+ maintain: 0,
718
816
  healthcheck: 0,
719
817
  'resource add': 2,
720
818
  'resource list': 1,
@@ -797,6 +895,15 @@ export async function runSlotlockCli(argv, io) {
797
895
  log.info('stopped', { forced });
798
896
  return 0;
799
897
  }
898
+ case 'maintain': {
899
+ const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
900
+ if (!result) {
901
+ io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
902
+ return 0;
903
+ }
904
+ io.stdout.write(`${JSON.stringify(result)}\n`);
905
+ return 0;
906
+ }
800
907
  case 'resource add': {
801
908
  const config = readSlotlockDatabaseConfig(io.env);
802
909
  const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.1.0",
6
+ "version": "0.2.0",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",
package/store.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Sql, TransactionSql } from 'postgres';
2
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
2
3
  import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventRetentionResult, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
3
- export declare const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
4
+ export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
4
5
  /**
5
6
  * Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
6
7
  * or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
@@ -120,7 +121,11 @@ export interface SlotlockStore {
120
121
  end: Date;
121
122
  source?: string;
122
123
  }): Promise<CreateReservationResult>;
123
- /** Idempotently create a tenant-owned booking reservation by its stable business identity. */
124
+ /**
125
+ * Idempotently create a tenant-owned booking reservation by its stable business identity. The
126
+ * rows a calendar event occupies are not bookings: here, in reschedule and in cancel their
127
+ * references answer as references no booking holds.
128
+ */
124
129
  createExternalReservation(params: ExternalReservationCommand): Promise<ExternalReservationResult>;
125
130
  /** Atomically move an existing tenant-owned booking; a conflict leaves its old range intact. */
126
131
  rescheduleExternalReservation(params: ExternalReservationCommand): Promise<ExternalReservationResult>;
@@ -141,10 +146,17 @@ export interface SlotlockStore {
141
146
  ttlMs: number;
142
147
  source?: string;
143
148
  }): Promise<AcquireHoldResult>;
144
- /** Flip an unexpired hold to a confirmed reservation (expiry cleared). */
145
- confirmHold(holdId: string): Promise<ConfirmHoldResult>;
146
- /** Delete a live hold; released=false when it was already gone (expired-reaped or confirmed). */
147
- releaseHold(holdId: string): Promise<ReleaseHoldResult>;
149
+ /**
150
+ * Flip an unexpired hold to a confirmed reservation (expiry cleared). Confirming it again answers
151
+ * the same reservation. With `tenantRef`, only a hold on one of that tenant's resources counts:
152
+ * another tenant's is `hold_not_found` whatever the connection's row-level security.
153
+ */
154
+ confirmHold(holdId: string, options?: SlotlockHoldTenantOptions): Promise<ConfirmHoldResult>;
155
+ /**
156
+ * Delete a live hold; released=false when it was already gone (expired-reaped or confirmed). With
157
+ * `tenantRef`, only a hold on one of that tenant's resources, as for confirmHold.
158
+ */
159
+ releaseHold(holdId: string, options?: SlotlockHoldTenantOptions): Promise<ReleaseHoldResult>;
148
160
  /** Reservations overlapping `window` for one resource, as merged-ready half-open intervals. */
149
161
  listBusy(resourceId: string, window: Interval): Promise<Interval[]>;
150
162
  /** Create or revise one trusted event under expected-revision + tenant idempotency guards. */
@@ -182,6 +194,10 @@ export interface SlotlockStore {
182
194
  /**
183
195
  * Atomically rematerialize due recurring masters into one rolling window without changing their
184
196
  * semantic revision. A collision rolls back that master and leaves its old coverage untouched.
197
+ * A master is rolled from the window's start through the later of its stored end and the
198
+ * window's, so its end never moves back. One that already covers the window, or whose end lies
199
+ * too far past it for the 367-day cap from the window's start, is not due: it is left as it is
200
+ * and not counted.
185
201
  */
186
202
  rollCalendarEventHorizon(params: {
187
203
  tenantRef: string;
@@ -215,7 +231,10 @@ export interface SlotlockStore {
215
231
  requiredSources?: string[];
216
232
  coverageMaxAgeMs?: number;
217
233
  }): Promise<CalendarFreeBusy>;
218
- /** Convenience composition: listBusy × expandRules (in the resource's stored timezone) → findNextAvailable. */
234
+ /**
235
+ * Convenience composition: listBusy × expandRules (in the resource's stored timezone) →
236
+ * findNextAvailable. A search window longer than 367 days is refused (`invalid_window`).
237
+ */
219
238
  findNextAvailableFor(params: {
220
239
  resourceId: string;
221
240
  rules: WeeklyAvailabilityRule[];
@@ -232,5 +251,8 @@ export interface SlotlockStoreOptions {
232
251
  /** Hard retained-command ceiling per non-internal owner. Defaults to 10,000; cannot be disabled. */
233
252
  agentOwnerCommandQuota?: number;
234
253
  }
254
+ export interface SlotlockHoldTenantOptions {
255
+ /** Only a hold on one of this tenant's resources: 1-500 UTF-8 bytes. */
256
+ tenantRef?: string;
257
+ }
235
258
  export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockStoreOptions): SlotlockStore;
236
- export {};
package/store.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { SLOTLOCK_CORE_DDL, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_TENANT_CONTEXT_SETTING, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, } from './ddl.js';
6
6
  import { findNextAvailable, mergeIntervals } from './engine.js';
7
- import { expandRules } from './rules.js';
7
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
8
8
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
9
9
  import { parseAvailabilityRules } from './weekly-hours.js';
10
10
  /** Postgres exclusion_violation — the EXCLUDE constraint fired. */
@@ -21,7 +21,7 @@ const SERIALIZATION_FAILURE = '40001';
21
21
  const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
22
  /** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
23
23
  const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
24
- export const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
24
+ export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
25
25
  /**
26
26
  * Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
27
27
  * or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
@@ -89,6 +89,19 @@ function resumeStoredSeries(event, window, found) {
89
89
  function seriesCursorJson(tx, cursor) {
90
90
  return cursor === null ? null : tx.json(cursor);
91
91
  }
92
+ /**
93
+ * The window a roll to `horizon` gives a series stored for `stored`: from the horizon's start to
94
+ * the later of the two ends, so its end never moves back. `null` keeps the stored window: it covers
95
+ * the horizon already, or it ends so far past it that no window from the horizon's start to its end
96
+ * fits the 367-day cap (it rolls once the horizon is close enough).
97
+ */
98
+ function rolledWindow(stored, horizon) {
99
+ if (stored.start <= horizon.start && stored.end >= horizon.end)
100
+ return null;
101
+ if (stored.end.getTime() - horizon.start.getTime() > MAX_CALENDAR_WINDOW_MS)
102
+ return null;
103
+ return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
104
+ }
92
105
  /** The roll's `after`, checked, or `undefined` for a pass's first batch. */
93
106
  function rollCursor(after) {
94
107
  if (after === undefined)
@@ -174,11 +187,6 @@ function isFiniteWindow(window) {
174
187
  Number.isFinite(window.end.getTime()) &&
175
188
  window.end.getTime() > window.start.getTime());
176
189
  }
177
- function invalidWindowError() {
178
- return Object.assign(new Error('Slotlock window must contain two finite dates with end > start'), {
179
- code: 'invalid_window',
180
- });
181
- }
182
190
  function isValidIdentity(value) {
183
191
  return value !== undefined && value.trim().length > 0 && Buffer.byteLength(value, 'utf8') <= 500;
184
192
  }
@@ -721,6 +729,18 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
721
729
  LIMIT 1`;
722
730
  return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
723
731
  }
732
+ /** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
733
+ function holdTenantRef(options) {
734
+ const tenantRef = options.tenantRef;
735
+ if (tenantRef === undefined)
736
+ return null;
737
+ if (!isValidIdentity(tenantRef)) {
738
+ throw Object.assign(new Error('Slotlock tenantRef must be 1-500 UTF-8 bytes'), {
739
+ code: 'invalid_tenant_ref',
740
+ });
741
+ }
742
+ return tenantRef;
743
+ }
724
744
  export function createSlotlockStore(sql, options = {}) {
725
745
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
726
746
  const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
@@ -1077,14 +1097,22 @@ export function createSlotlockStore(sql, options = {}) {
1077
1097
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}`;
1078
1098
  if (cancelled[0])
1079
1099
  return { ok: false, code: 'reservation_cancelled' };
1100
+ // A calendar event's occupation carries a reference in this namespace too, but it is
1101
+ // not a booking: create, reschedule and cancel never read, replay, move or free it,
1102
+ // and answer as for a reference no booking holds. Bookings have their own unique index.
1080
1103
  const existing = await tx `
1081
1104
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
1082
1105
  buffer_after_ms, revision, source
1083
1106
  FROM slotlock.reservations
1084
1107
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
1108
+ AND calendar_event_id IS NULL
1085
1109
  FOR UPDATE`;
1086
1110
  const found = existing[0];
1087
1111
  if (found) {
1112
+ // The booking has moved on since: a delayed create is stale, as a delayed move is.
1113
+ if (params.revision < Number(found.revision)) {
1114
+ return { ok: false, code: 'stale_revision' };
1115
+ }
1088
1116
  if (!sameExternalPayload(found, params)) {
1089
1117
  return { ok: false, code: 'idempotency_conflict' };
1090
1118
  }
@@ -1138,6 +1166,7 @@ export function createSlotlockStore(sql, options = {}) {
1138
1166
  buffer_after_ms, revision, source
1139
1167
  FROM slotlock.reservations
1140
1168
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
1169
+ AND calendar_event_id IS NULL
1141
1170
  FOR UPDATE`;
1142
1171
  const found = existing[0];
1143
1172
  if (!found) {
@@ -1222,6 +1251,7 @@ export function createSlotlockStore(sql, options = {}) {
1222
1251
  const existing = await tx `
1223
1252
  SELECT id, revision FROM slotlock.reservations
1224
1253
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
1254
+ AND calendar_event_id IS NULL
1225
1255
  FOR UPDATE`;
1226
1256
  const found = existing[0];
1227
1257
  if (found) {
@@ -1294,11 +1324,14 @@ export function createSlotlockStore(sql, options = {}) {
1294
1324
  }
1295
1325
  }
1296
1326
  },
1297
- async confirmHold(holdId) {
1327
+ async confirmHold(holdId, options = {}) {
1328
+ const tenantRef = holdTenantRef(options);
1298
1329
  const rows = await sql `
1299
1330
  UPDATE slotlock.reservations
1300
1331
  SET status = 'confirmed', expires_at = NULL
1301
1332
  WHERE id = ${holdId} AND status = 'held' AND expires_at > now()
1333
+ AND (${tenantRef}::text IS NULL
1334
+ OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
1302
1335
  RETURNING id, resource_id, starts_at, ends_at, source`;
1303
1336
  const row = rows[0];
1304
1337
  if (row) {
@@ -1320,7 +1353,13 @@ export function createSlotlockStore(sql, options = {}) {
1320
1353
  const present = await sql `
1321
1354
  SELECT status, (status = 'held' AND expires_at <= now()) AS expired,
1322
1355
  resource_id, starts_at, ends_at, source
1323
- FROM slotlock.reservations WHERE id = ${holdId}`;
1356
+ FROM slotlock.reservations
1357
+ WHERE id = ${holdId}
1358
+ -- A hold has no external identity, before or after it is confirmed; a booking that has
1359
+ -- one is not a hold, and confirming it again would describe it wrongly.
1360
+ AND tenant_ref IS NULL
1361
+ AND (${tenantRef}::text IS NULL
1362
+ OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))`;
1324
1363
  const found = present[0];
1325
1364
  // Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
1326
1365
  // SUCCESS — the booking exists; reporting failure would make callers double-book elsewhere.
@@ -1347,10 +1386,13 @@ export function createSlotlockStore(sql, options = {}) {
1347
1386
  // taken; both refusal codes are terminal for the caller.
1348
1387
  return { ok: false, code: 'hold_not_found' };
1349
1388
  },
1350
- async releaseHold(holdId) {
1389
+ async releaseHold(holdId, options = {}) {
1390
+ const tenantRef = holdTenantRef(options);
1351
1391
  const rows = await sql `
1352
1392
  DELETE FROM slotlock.reservations
1353
1393
  WHERE id = ${holdId} AND status = 'held'
1394
+ AND (${tenantRef}::text IS NULL
1395
+ OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
1354
1396
  RETURNING id`;
1355
1397
  return { ok: true, released: rows.length > 0 };
1356
1398
  },
@@ -1704,15 +1746,18 @@ export function createSlotlockStore(sql, options = {}) {
1704
1746
  });
1705
1747
  }
1706
1748
  const after = rollCursor(params.after);
1749
+ // Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
1750
+ // of its start.
1707
1751
  const candidates = await sql `
1708
1752
  SELECT id, materialized_ends_at
1709
1753
  FROM slotlock.calendar_events
1710
1754
  WHERE tenant_ref = ${params.tenantRef}
1711
1755
  AND recurrence_rule IS NOT NULL
1712
1756
  AND (
1713
- materialized_starts_at <> ${window.start}
1757
+ materialized_starts_at > ${window.start}
1714
1758
  OR materialized_ends_at < ${window.end}
1715
1759
  )
1760
+ AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_CALENDAR_WINDOW_MS)}
1716
1761
  AND (
1717
1762
  ${after?.materializedEndsAt ?? null}::timestamptz IS NULL
1718
1763
  OR (materialized_ends_at, id) >
@@ -1738,16 +1783,15 @@ export function createSlotlockStore(sql, options = {}) {
1738
1783
  FROM slotlock.calendar_events
1739
1784
  WHERE id = ${candidate.id} AND tenant_ref = ${params.tenantRef}
1740
1785
  FOR UPDATE`;
1741
- if (!row ||
1742
- row.recurrence_rule === null ||
1743
- (row.materialized_starts_at.getTime() === window.start.getTime() &&
1744
- row.materialized_ends_at >= window.end)) {
1786
+ if (!row || row.recurrence_rule === null)
1787
+ return false;
1788
+ const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, window);
1789
+ if (!rolled)
1745
1790
  return false;
1746
- }
1747
1791
  const childMap = await loadCalendarEventChildren(tx, [row.id]);
1748
1792
  const event = toCalendarEvent(row, childMap.get(row.id));
1749
1793
  // From where its last expansion stopped, when that can be trusted; else its start.
1750
- const { occurrences, cursor } = expandCalendarEventSeries(event, window, {
1794
+ const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
1751
1795
  cursor: row.recurrence_cursor ?? null,
1752
1796
  stored: true,
1753
1797
  });
@@ -1761,8 +1805,8 @@ export function createSlotlockStore(sql, options = {}) {
1761
1805
  });
1762
1806
  await tx `
1763
1807
  UPDATE slotlock.calendar_events
1764
- SET materialized_starts_at = ${window.start},
1765
- materialized_ends_at = ${window.end},
1808
+ SET materialized_starts_at = ${rolled.start},
1809
+ materialized_ends_at = ${rolled.end},
1766
1810
  recurrence_cursor = ${seriesCursorJson(tx, cursor)}
1767
1811
  WHERE id = ${row.id} AND tenant_ref = ${params.tenantRef}`;
1768
1812
  return true;
@@ -2157,19 +2201,16 @@ export function createSlotlockStore(sql, options = {}) {
2157
2201
  AND event.transparency = 'opaque'
2158
2202
  )
2159
2203
  SELECT count(*)::int AS source_count,
2204
+ -- Complete when what is stored runs from where the series can first occur in the
2205
+ -- window (its start, if that is later) to the window's end: a series stored only
2206
+ -- from after its start leaves the occurrences before that unknown.
2160
2207
  bool_and(
2161
2208
  materialized_ends_at >= ${window.end}
2162
- AND (
2163
- earliest_possible_at >= ${window.start}
2164
- OR materialized_starts_at <= ${window.start}
2165
- )
2209
+ AND materialized_starts_at <= GREATEST(earliest_possible_at, ${window.start})
2166
2210
  ) AS complete,
2167
2211
  bool_or(
2168
2212
  materialized_ends_at > ${window.start}
2169
- AND (
2170
- earliest_possible_at >= ${window.start}
2171
- OR materialized_starts_at < ${window.end}
2172
- )
2213
+ AND materialized_starts_at < ${window.end}
2173
2214
  ) AS overlaps
2174
2215
  FROM recurring_sources
2175
2216
  WHERE earliest_possible_at < ${window.end}`;
@@ -2222,6 +2263,7 @@ export function createSlotlockStore(sql, options = {}) {
2222
2263
  async findNextAvailableFor(params) {
2223
2264
  if (!isFiniteWindow(params.searchWindow))
2224
2265
  throw invalidWindowError();
2266
+ assertBoundedSearchWindow(params.searchWindow);
2225
2267
  if (!Number.isFinite(params.durationMs) || params.durationMs <= 0)
2226
2268
  return null;
2227
2269
  // Rules are authored in the resource's zone — expansion must honour it (DST-correct).