slotlock 0.5.2 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
package/rest-api.js CHANGED
@@ -404,6 +404,18 @@ export function slotlockOpenApiDocument(args) {
404
404
  resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
405
405
  start: { type: 'string', description: 'An overlap: the start it refused.' },
406
406
  end: { type: 'string', description: 'An overlap: the end it refused.' },
407
+ issues: {
408
+ type: 'array',
409
+ description: '`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.',
410
+ items: {
411
+ type: 'object',
412
+ required: ['path', 'message'],
413
+ properties: {
414
+ path: { type: 'array', items: { type: ['string', 'integer'] } },
415
+ message: { type: 'string' },
416
+ },
417
+ },
418
+ },
407
419
  error: {
408
420
  type: 'object',
409
421
  required: ['code'],
package/self-host.js CHANGED
@@ -8,7 +8,7 @@ import { createHash, 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
- import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
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
14
  import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
@@ -390,8 +390,13 @@ export async function migrateSlotlock(config, log) {
390
390
  const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
391
391
  try {
392
392
  const store = createSlotlockStore(owner);
393
- await store.applySchema();
394
- await store.applyTenantRls();
393
+ try {
394
+ await store.applySchema();
395
+ await store.applyTenantRls();
396
+ }
397
+ catch (error) {
398
+ throw await migrateRefusal(owner, config, error);
399
+ }
395
400
  log.info('schema_applied');
396
401
  if (config.migrateDatabaseUrl === undefined)
397
402
  return;
@@ -412,6 +417,36 @@ export async function migrateSlotlock(config, log) {
412
417
  await owner.end({ timeout: 5 });
413
418
  }
414
419
  }
420
+ /**
421
+ * Migrate run as the serving role, with no SLOTLOCK_MIGRATE_DATABASE_URL, is refused: as
422
+ * `unsafe_slotlock_schema`, with one reason per object the role does not own, when a role that is
423
+ * not a superuser owns the schema, and with a permission error (42501) when a superuser does. Lead
424
+ * with the fix, and keep the code, the reasons and the database's message after it.
425
+ */
426
+ async function migrateRefusal(sql, config, error) {
427
+ const code = error?.code;
428
+ if (config.migrateDatabaseUrl !== undefined ||
429
+ !(error instanceof Error) ||
430
+ (code !== 'unsafe_slotlock_schema' && code !== '42501')) {
431
+ return error;
432
+ }
433
+ let found;
434
+ try {
435
+ [found] = await sql `
436
+ SELECT current_user AS role, pg_catalog.pg_get_userbyid(ns.nspowner) AS owner
437
+ FROM pg_catalog.pg_namespace ns
438
+ WHERE ns.nspname = 'slotlock'`;
439
+ }
440
+ catch {
441
+ return error;
442
+ }
443
+ if (!found || found.role === found.owner)
444
+ return error;
445
+ return Object.assign(new Error(`set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL: migrate connected as ${found.role} (DATABASE_URL), but ${found.owner} owns the slotlock schema, and only its owner can migrate it. DATABASE_URL stays the serving role's.\n${error.message}`, { cause: error }), {
446
+ code,
447
+ ...('reasons' in error ? { reasons: error.reasons } : {}),
448
+ });
449
+ }
415
450
  async function databaseReachable(sql) {
416
451
  let timeout;
417
452
  try {
@@ -533,6 +568,19 @@ export async function startSlotlockServer(config, log) {
533
568
  if (!schema?.present) {
534
569
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
535
570
  }
571
+ // Serving as the schema owner is allowed (one role can migrate and serve), but the owner can
572
+ // lift forced row-level security, drop the exclusion constraints and read any table that does
573
+ // not force it: say so once, at startup, rather than refuse.
574
+ const [owning] = await sql `
575
+ SELECT pg_catalog.pg_has_role(current_user, ns.nspowner, 'MEMBER') AS owns
576
+ FROM pg_catalog.pg_namespace ns
577
+ WHERE ns.nspname = 'slotlock'`;
578
+ if (owning?.owns) {
579
+ log.warn('serving_as_schema_owner', {
580
+ role: serving?.role,
581
+ message: "DATABASE_URL connects as the role that owns the slotlock schema. Row-level security does not confine its owner on tables that do not force it, and the owner can lift it: serve as a role that owns nothing (CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS) and set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL so migrate grants it what it needs",
582
+ });
583
+ }
536
584
  // Migrated by another release: an older schema needs this release's migrate first, and a newer
537
585
  // one has tables and rules this release's code does not know.
538
586
  await assertSlotlockSchemaCurrent(sql);
@@ -657,6 +705,8 @@ export async function startSlotlockServer(config, log) {
657
705
  return true;
658
706
  },
659
707
  subscriptions: {
708
+ // Live updates poll each tenant's change feed (one reader per tenant).
709
+ changes: createSlotlockStoreChangeSource(store, sql),
660
710
  maxTotal: caps.subscriptions,
661
711
  maxPerTenant: caps.tenant_subscriptions,
662
712
  maxPerPrincipal: slotlockKeySubscriptions(caps),
@@ -789,7 +839,9 @@ export async function startSlotlockServer(config, log) {
789
839
  send,
790
840
  feed: {
791
841
  claimWebhookDeliveries: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.claimWebhookDeliveries(params)),
792
- settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params)),
842
+ settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params), {
843
+ origin: 'maintenance',
844
+ }),
793
845
  },
794
846
  }),
795
847
  // Counts only, never the tenant or a URL.
@@ -1551,7 +1603,7 @@ export async function runSlotlockCli(argv, io) {
1551
1603
  }
1552
1604
  case 'key rotate': {
1553
1605
  const config = readSlotlockDatabaseConfig(io.env);
1554
- const rotated = await withApiKeys(config, (keys) => keys.rotate({ tenantRef: config.tenantRef, id: operands[1] }));
1606
+ const rotated = await withApiKeys(config, (keys) => keys.rotate({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
1555
1607
  if (!rotated) {
1556
1608
  io.stderr.write('slotlock: no active API key with that id in this tenant\n');
1557
1609
  return 1;
@@ -1562,7 +1614,7 @@ export async function runSlotlockCli(argv, io) {
1562
1614
  }
1563
1615
  case 'key revoke': {
1564
1616
  const config = readSlotlockDatabaseConfig(io.env);
1565
- const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1] }));
1617
+ const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
1566
1618
  if (!revoked) {
1567
1619
  io.stderr.write('slotlock: no API key with that id in this tenant\n');
1568
1620
  return 1;
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.5.2",
6
+ "version": "0.6.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,8 +1,8 @@
1
- import { type SlotlockEventFeed } from './event-feed.js';
1
+ import { type SlotlockChangeOrigin, type SlotlockEventFeed } from './event-feed.js';
2
2
  import { type SlotlockBookingRules } from './booking-rules.js';
3
3
  import type { Sql, TransactionSql } from 'postgres';
4
4
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
5
- import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventRetentionResult, BookingRetentionResult, SlotlockTenantErasure, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockAgendaEntry, SlotlockHold, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
5
+ import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventRetentionResult, BookingRetentionResult, SlotlockTenantErasure, SlotlockBookingRuleDefaults, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockAgendaEntry, SlotlockHold, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
6
6
  export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
7
7
  /**
8
8
  * Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
@@ -90,6 +90,8 @@ export declare function legacyPutCommandHash(params: Parameters<typeof putComman
90
90
  }): string;
91
91
  /** Members a pool booking or search considers at most, in id order. */
92
92
  export declare const SLOTLOCK_MAX_POOL_MEMBERS = 100;
93
+ /** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
94
+ export declare const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1000;
93
95
  export declare function isPoolName(value: unknown): value is string;
94
96
  export interface SlotlockStore extends SlotlockEventFeed {
95
97
  /**
@@ -148,6 +150,13 @@ export interface SlotlockStore extends SlotlockEventFeed {
148
150
  * without control characters: the history (listHistory) records it with each change.
149
151
  */
150
152
  actor?: string;
153
+ /**
154
+ * Where the changes come from (SLOTLOCK_CHANGE_ORIGINS: `mcp`, `rest`, `dashboard`, ...),
155
+ * recorded with each beside the actor. Unset, the enclosing withTenant's, else `library`.
156
+ */
157
+ origin?: SlotlockChangeOrigin;
158
+ /** The id (a UUID) of the API key making these changes, recorded with each. */
159
+ keyId?: string;
151
160
  }): Promise<T>;
152
161
  /**
153
162
  * INVARIANT (erasure completeness): a tenant integration must use a stable, erasable business
@@ -228,6 +237,24 @@ export interface SlotlockStore extends SlotlockEventFeed {
228
237
  id: string;
229
238
  rules: SlotlockBookingRules | null;
230
239
  }): Promise<SlotlockResource | null>;
240
+ /**
241
+ * Set (or clear, with `null`) the tenant's default booking rules, or one pool's (`pool`): a
242
+ * resource's rules as they apply (`effectiveBookingRules`) are the tenant's, overlaid by its
243
+ * pool's, overlaid by its own, a field set at a narrower level winning. They apply to bookings made
244
+ * from then on. `checkInMinutes` is only a resource's own (`invalid_booking_rules`, as for rules
245
+ * outside their bounds); `pool` is a pool name (`invalid_pool`). At most
246
+ * SLOTLOCK_MAX_POOL_RULE_DEFAULTS pools a tenant have them (`pool_rules_limit_reached` for one
247
+ * more). Returns the rules stored.
248
+ */
249
+ setBookingRuleDefaults(params: {
250
+ tenantRef: string;
251
+ pool: string | null;
252
+ rules: SlotlockBookingRules | null;
253
+ }): Promise<SlotlockBookingRules | null>;
254
+ /** The tenant's default booking rules, and each pool's that has them, by pool name. */
255
+ getBookingRuleDefaults(params: {
256
+ tenantRef: string;
257
+ }): Promise<SlotlockBookingRuleDefaults>;
231
258
  /**
232
259
  * Put one resource in a pool, or take it out (`null`): `pool` is 1-100 characters of a-z, 0-9,
233
260
  * `.`, `_` and `-`, starting with a letter or digit (`invalid_pool` otherwise). A resource is in
@@ -870,6 +897,11 @@ export type CheckInReservationResult = {
870
897
  /** One source's coverage of a resource, with its health (listCoverage). */
871
898
  export interface SlotlockCoverageHealth {
872
899
  source: string;
900
+ /**
901
+ * How the change feed names the source, never the source itself: `src_` and 32 random hex
902
+ * digits, made when the source is first recorded for the resource and kept until it is deleted.
903
+ */
904
+ sourceId: string;
873
905
  start: Date;
874
906
  end: Date;
875
907
  revision: number;
@@ -974,4 +1006,4 @@ export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockSto
974
1006
  * Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
975
1007
  * Maintenance (rolling series, pruning, purging) and erasure are not among them.
976
1008
  */
977
- export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist", "putAssistantSettings", "deleteAssistantSettings", "putAssistantConversation", "settleAssistantAction", "deleteAssistantConversation"];
1009
+ export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setBookingRuleDefaults", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist", "putAssistantSettings", "deleteAssistantSettings", "putAssistantConversation", "settleAssistantAction", "deleteAssistantConversation"];