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.
@@ -1,3 +1,5 @@
1
+ import type { Sql } from 'postgres';
2
+ import type { SlotlockAgentChangeSource } from './change-watch.js';
1
3
  import { type SlotlockAgentCalendarBackend, type SlotlockAgentInvocationContext } from './agent-server.js';
2
4
  import type { SlotlockApplicationBackend } from './application-api.js';
3
5
  import { type SlotlockStore } from './store.js';
@@ -23,6 +25,13 @@ export declare function projectSlotlockCalendarEvent(event: SlotlockCalendarEven
23
25
  * stored 409. rest-api.ts declares the 404 and 409 each route can answer from this.
24
26
  */
25
27
  export declare function slotlockAgentErrorStatus(code: string): 400 | 404 | 409 | 429;
28
+ /**
29
+ * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
30
+ * under its row-level security, one indexed query per read. Readers poll it (change-watch.ts:
31
+ * every `SLOTLOCK_FEED_BUSY_MS` after a read that found changes, backing off to
32
+ * `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
33
+ */
34
+ export declare function createSlotlockStoreChangeSource(store: SlotlockStore, sql: Sql): SlotlockAgentChangeSource;
26
35
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
27
36
  export declare function createSlotlockStoreAgentBackend(store: SlotlockStore, options: SlotlockStoreAgentBackendOptions): SlotlockAgentCalendarBackend;
28
37
  export interface SlotlockStoreApplicationBackendOptions {
@@ -1,7 +1,8 @@
1
+ import { slotlockChangeFeedHead } from './event-feed.js';
1
2
  import { slotlockCloudEvent } from './webhooks.js';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { SlotlockAgentOperationError, } from './agent-server.js';
4
- import { bookingSearch } from './booking-rules.js';
5
+ import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
5
6
  import { findNextAvailable, mergeIntervals } from './engine.js';
6
7
  import { expandRules } from './rules.js';
7
8
  import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
@@ -14,6 +15,9 @@ import { parseAvailabilityRules } from './weekly-hours.js';
14
15
  * and refuses it otherwise.
15
16
  */
16
17
  const WRITE = { isolation: 'read committed' };
18
+ // The UUIDs withTenant takes as a keyId (versions 1-5, as Slotlock's keys are): any other a custom
19
+ // authenticator's principal carries is left out, not refused.
20
+ const KEY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
21
  /** Who a call's changes are recorded as in the history: the principal's subject, kept short. */
18
22
  function actorOf(context) {
19
23
  const subject = context.principal.subject;
@@ -31,10 +35,14 @@ async function withTenant(store, context, callback, options) {
31
35
  let value;
32
36
  try {
33
37
  const actor = actorOf(context);
38
+ const keyId = context.principal.keyId;
34
39
  value = await store.withTenant(context.principal.tenantRef, callback, {
35
40
  ...options,
36
41
  signal: context.signal,
37
42
  ...(actor !== undefined ? { actor } : {}),
43
+ ...(context.origin !== undefined ? { origin: context.origin } : {}),
44
+ // An authenticator's own principal may carry something else there: then it is left out.
45
+ ...(typeof keyId === 'string' && KEY_ID.test(keyId) ? { keyId } : {}),
38
46
  });
39
47
  }
40
48
  catch (error) {
@@ -315,6 +323,21 @@ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
315
323
  function requireAgentResource(tenantStore, tenantRef, resourceId) {
316
324
  return requireResource(tenantStore, tenantRef, resourceId, 'shared');
317
325
  }
326
+ /**
327
+ * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
328
+ * under its row-level security, one indexed query per read. Readers poll it (change-watch.ts:
329
+ * every `SLOTLOCK_FEED_BUSY_MS` after a read that found changes, backing off to
330
+ * `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
331
+ */
332
+ export function createSlotlockStoreChangeSource(store, sql) {
333
+ return {
334
+ head: () => slotlockChangeFeedHead(sql),
335
+ async listChanges(params) {
336
+ const page = await store.withTenant(params.tenantRef, (tenant) => tenant.listChanges(params));
337
+ return page.ok ? { ok: true, changes: page.changes, next: page.next } : page;
338
+ },
339
+ };
340
+ }
318
341
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
319
342
  export function createSlotlockStoreAgentBackend(store, options) {
320
343
  return {
@@ -457,7 +480,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
457
480
  // Not free either where its booking rules refuse a booking: before its notice, past how
458
481
  // far ahead it books, and, when it enforces them, outside its bookable hours. Its slot
459
482
  // step is not counted: it moves when a booking may start, not whether the member is free.
460
- const rules = member.bookingRules;
483
+ const rules = member.effectiveBookingRules;
461
484
  const search = bookingSearch({ rules, timezone: member.timezone, window, now });
462
485
  const latestStart = rules?.maxAdvanceDays !== undefined
463
486
  ? now.getTime() + rules.maxAdvanceDays * 86_400_000
@@ -682,7 +705,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
682
705
  // Only what a booking made now may take: the resource's booking rules bound the search,
683
706
  // as they bound the write that follows it.
684
707
  const search = bookingSearch({
685
- rules: resource.bookingRules,
708
+ rules: resource.effectiveBookingRules,
686
709
  timezone: resource.timezone,
687
710
  window,
688
711
  now: new Date(),
@@ -691,11 +714,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
691
714
  const freeBusy = await readFreeBusy(tenantStore, {
692
715
  tenantRef: context.principal.tenantRef,
693
716
  resourceId,
694
- // A slot's turnaround may run past the window: what it reaches must be free too.
695
- window: {
696
- start: window.start,
697
- end: new Date(window.end.getTime() + (search?.bufferAfterMs ?? 0)),
698
- },
717
+ // A slot's buffers may run past the window: what they reach must be free too.
718
+ window: search ? bookingSearchReach({ ...search, window }) : window,
699
719
  requiredSources: [...sources],
700
720
  ...(options.coverageMaxAgeMs !== undefined
701
721
  ? { coverageMaxAgeMs: options.coverageMaxAgeMs }
@@ -713,12 +733,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
713
733
  windowsLeft -= windows.length;
714
734
  if (windowsLeft < 0)
715
735
  throw new SlotlockAgentOperationError('calendar_work_limit', 400);
716
- // A slot [s, s + d) with its turnaround meets busy [b, e) when [s, s + d) meets [b - t, e).
717
736
  const candidate = findNextAvailable({
718
- busy: freeBusy.busy.map((busy) => ({
719
- start: new Date(busy.start.getTime() - search.bufferAfterMs),
720
- end: busy.end,
721
- })),
737
+ busy: bookingSearchBlocked(freeBusy.busy, search),
722
738
  windows,
723
739
  durationMs,
724
740
  ...(search.align ? { align: search.align } : {}),
@@ -979,7 +995,9 @@ function storeRefusal(error) {
979
995
  const code = error?.code;
980
996
  if (typeof code !== 'string')
981
997
  return error;
982
- if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
998
+ if (code === 'resource_limit_reached' ||
999
+ code === 'resource_visibility_conflict' ||
1000
+ code === 'pool_rules_limit_reached') {
983
1001
  return new SlotlockAgentOperationError(code, 409);
984
1002
  }
985
1003
  if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
@@ -1021,7 +1039,9 @@ function waitlistOutput(entry) {
1021
1039
  */
1022
1040
  async function offerFreedTime(store, tenantRef) {
1023
1041
  try {
1024
- await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }));
1042
+ await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
1043
+ origin: 'maintenance',
1044
+ });
1025
1045
  }
1026
1046
  catch {
1027
1047
  // Left to maintenance.
@@ -1073,6 +1093,8 @@ function bookingRulesOutput(rules) {
1073
1093
  ...(rules.minNoticeMinutes !== undefined ? { min_notice_minutes: rules.minNoticeMinutes } : {}),
1074
1094
  ...(rules.maxAdvanceDays !== undefined ? { max_advance_days: rules.maxAdvanceDays } : {}),
1075
1095
  ...(rules.slotStepMinutes !== undefined ? { slot_step_minutes: rules.slotStepMinutes } : {}),
1096
+ ...(rules.stepOffsetMinutes !== undefined ? { step_offset_minutes: rules.stepOffsetMinutes } : {}),
1097
+ ...(rules.bufferBeforeMinutes !== undefined ? { buffer_before_minutes: rules.bufferBeforeMinutes } : {}),
1076
1098
  ...(rules.turnaroundMinutes !== undefined ? { turnaround_minutes: rules.turnaroundMinutes } : {}),
1077
1099
  ...(rules.enforceHours !== undefined ? { enforce_hours: rules.enforceHours } : {}),
1078
1100
  ...(rules.checkInMinutes !== undefined ? { check_in_minutes: rules.checkInMinutes } : {}),
@@ -1090,6 +1112,11 @@ function bookingRulesInput(value) {
1090
1112
  rules.maxAdvanceDays = input.max_advance_days;
1091
1113
  if (input.slot_step_minutes !== undefined)
1092
1114
  rules.slotStepMinutes = input.slot_step_minutes;
1115
+ if (input.step_offset_minutes !== undefined)
1116
+ rules.stepOffsetMinutes = input.step_offset_minutes;
1117
+ if (input.buffer_before_minutes !== undefined) {
1118
+ rules.bufferBeforeMinutes = input.buffer_before_minutes;
1119
+ }
1093
1120
  if (input.turnaround_minutes !== undefined)
1094
1121
  rules.turnaroundMinutes = input.turnaround_minutes;
1095
1122
  if (input.enforce_hours !== undefined)
@@ -1107,6 +1134,7 @@ function resourceOutput(resource) {
1107
1134
  timezone: resource.timezone,
1108
1135
  availability: rulesOutput(resource.availabilityRules),
1109
1136
  booking_rules: bookingRulesOutput(resource.bookingRules),
1137
+ effective_booking_rules: bookingRulesOutput(resource.effectiveBookingRules),
1110
1138
  visibility: resource.visibility ?? 'shared',
1111
1139
  archived_at: resource.archivedAt?.toISOString() ?? null,
1112
1140
  pool: resource.pool ?? null,
@@ -1203,6 +1231,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1203
1231
  type: entry.type,
1204
1232
  time: entry.occurredAt.toISOString(),
1205
1233
  actor: entry.actor,
1234
+ origin: entry.origin,
1235
+ key_id: entry.keyId,
1206
1236
  data: entry.data,
1207
1237
  })),
1208
1238
  next_before: entries.length === limit ? (entries.at(-1)?.id ?? null) : null,
@@ -1405,6 +1435,43 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1405
1435
  pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
1406
1436
  }));
1407
1437
  },
1438
+ async getBookingRuleDefaults(context) {
1439
+ return withTenant(store, context, async (tenant) => {
1440
+ const defaults = await tenant.getBookingRuleDefaults({ tenantRef: tenantOf(context) });
1441
+ return {
1442
+ tenant: bookingRulesOutput(defaults.tenant ?? undefined),
1443
+ pools: Object.entries(defaults.pools).map(([pool, rules]) => ({
1444
+ pool,
1445
+ rules: bookingRulesOutput(rules) ?? {},
1446
+ })),
1447
+ };
1448
+ });
1449
+ },
1450
+ async setTenantBookingRules(context, input) {
1451
+ const rules = bookingRulesInput(input.rules);
1452
+ return withTenant(store, context, async (tenant) => {
1453
+ try {
1454
+ const stored = await tenant.setBookingRuleDefaults({ tenantRef: tenantOf(context), pool: null, rules });
1455
+ return { rules: bookingRulesOutput(stored ?? undefined) };
1456
+ }
1457
+ catch (error) {
1458
+ throw storeRefusal(error);
1459
+ }
1460
+ }, WRITE);
1461
+ },
1462
+ async setPoolBookingRules(context, input) {
1463
+ const rules = bookingRulesInput(input.rules);
1464
+ const pool = input.pool;
1465
+ return withTenant(store, context, async (tenant) => {
1466
+ try {
1467
+ const stored = await tenant.setBookingRuleDefaults({ tenantRef: tenantOf(context), pool, rules });
1468
+ return { pool, rules: bookingRulesOutput(stored ?? undefined) };
1469
+ }
1470
+ catch (error) {
1471
+ throw storeRefusal(error);
1472
+ }
1473
+ }, WRITE);
1474
+ },
1408
1475
  async setTenantAvailability(context, input) {
1409
1476
  const rules = rulesInput(input.rules);
1410
1477
  return withTenant(store, context, async (tenant) => {
@@ -1616,6 +1683,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1616
1683
  return {
1617
1684
  sources: sources.map((source) => ({
1618
1685
  source: source.source,
1686
+ source_id: source.sourceId,
1619
1687
  start: source.start.toISOString(),
1620
1688
  end: source.end.toISOString(),
1621
1689
  revision: source.revision,
package/api-keys.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type SlotlockAgentPrincipal } from './agent-server.js';
2
2
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
3
+ import { type SlotlockChangeOrigin } from './event-feed.js';
3
4
  import type { SlotlockSql } from './store.js';
4
5
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
5
6
  /**
@@ -46,6 +47,15 @@ export interface CreateSlotlockApiKeyInput {
46
47
  /** Omit for a key that lives until it is revoked. At most ten years ahead. */
47
48
  expiresAt?: Date;
48
49
  createdBy?: string;
50
+ /** Who is creating it, for the feed's `key.created` (and withTenant's actor); else `createdBy`. */
51
+ actor?: string;
52
+ /** Where from, for the feed (`dashboard`, say); else the enclosing withTenant's, or `library`. */
53
+ origin?: SlotlockChangeOrigin;
54
+ }
55
+ /** Who rotates or revokes a key and from where, recorded on the feed as `create`'s are. */
56
+ export interface SlotlockApiKeyChange {
57
+ actor?: string;
58
+ origin?: SlotlockChangeOrigin;
49
59
  }
50
60
  export interface SlotlockApiKeyStore {
51
61
  /**
@@ -71,7 +81,7 @@ export interface SlotlockApiKeyStore {
71
81
  rotate(input: {
72
82
  tenantRef: string;
73
83
  id: string;
74
- }): Promise<{
84
+ } & SlotlockApiKeyChange): Promise<{
75
85
  key: string;
76
86
  apiKey: SlotlockApiKey;
77
87
  } | null>;
@@ -82,7 +92,7 @@ export interface SlotlockApiKeyStore {
82
92
  revoke(input: {
83
93
  tenantRef: string;
84
94
  id: string;
85
- }): Promise<SlotlockApiKey | null>;
95
+ } & SlotlockApiKeyChange): Promise<SlotlockApiKey | null>;
86
96
  /** Resolve a presented key to its tenant and scopes; null unless it is active. */
87
97
  authenticate(key: string): Promise<SlotlockApiKeyIdentity | null>;
88
98
  /**
package/api-keys.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { createHash, randomBytes } from 'node:crypto';
11
11
  import { SlotlockAgentOperationError, } from './agent-server.js';
12
12
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
13
+ import { isSlotlockChangeOrigin } from './event-feed.js';
13
14
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
14
15
  /**
15
16
  * `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
@@ -144,6 +145,33 @@ async function readCommitted(sql, query) {
144
145
  }
145
146
  return query(sql);
146
147
  }
148
+ /**
149
+ * Run a key change with who made it and from where set for the row the database appends to the
150
+ * tenant's feed, in a READ COMMITTED transaction (or the caller's), and the caller's values put back.
151
+ */
152
+ async function asChange(sql, change, query) {
153
+ if (change.actor !== undefined && !/^[^\u0000-\u001f\u007f]{1,200}$/u.test(change.actor)) {
154
+ throw invalid('invalid_identity', 'Slotlock actor must be 1-200 characters without control characters');
155
+ }
156
+ if (change.origin !== undefined && !isSlotlockChangeOrigin(change.origin)) {
157
+ throw invalid('invalid_identity', 'Slotlock origin is not one of SLOTLOCK_CHANGE_ORIGINS');
158
+ }
159
+ return readCommitted(sql, async (tx) => {
160
+ const [previous] = await tx `
161
+ SELECT current_setting('slotlock.actor', true) AS actor,
162
+ current_setting('slotlock.origin', true) AS origin`;
163
+ await tx `
164
+ SELECT set_config('slotlock.actor', COALESCE(${change.actor ?? null}::text, ${previous?.actor ?? ''}), true),
165
+ set_config('slotlock.origin', COALESCE(${change.origin ?? null}::text,
166
+ NULLIF(${previous?.origin ?? ''}, ''), 'library'), true)`;
167
+ const result = await query(tx);
168
+ // Only after success: a failed query has aborted the transaction, which takes the values with it.
169
+ await tx `
170
+ SELECT set_config('slotlock.actor', ${previous?.actor ?? ''}, true),
171
+ set_config('slotlock.origin', ${previous?.origin ?? ''}, true)`;
172
+ return result;
173
+ });
174
+ }
147
175
  /**
148
176
  * API keys in the `slotlock` schema, through the connection's role. Every input is checked here
149
177
  * before a query runs; the functions and the table's constraints check the same rules again.
@@ -178,7 +206,7 @@ export function createSlotlockApiKeyStore(sql) {
178
206
  const { key, prefix, digest } = generateSlotlockApiKey();
179
207
  // Scopes travel as one string: postgres.js binds `sql.array` as text[] only once the pool
180
208
  // has fetched the server's array types, which the first query on a new pool precedes.
181
- const [row] = await readCommitted(sql, (tx) => tx `
209
+ const [row] = await asChange(sql, { ...(input.actor !== undefined ? { actor: input.actor } : {}), ...(input.origin ? { origin: input.origin } : {}) }, (tx) => tx `
182
210
  SELECT * FROM slotlock.create_api_key(
183
211
  ${input.tenantRef}::text,
184
212
  ${name}::text,
@@ -199,23 +227,23 @@ export function createSlotlockApiKeyStore(sql) {
199
227
  SELECT * FROM slotlock.list_api_keys(${tenantRef}::text)`;
200
228
  return rows.map(apiKeyFrom);
201
229
  },
202
- async rotate({ tenantRef, id }) {
230
+ async rotate({ tenantRef, id, ...change }) {
203
231
  assertTenantRef(tenantRef);
204
232
  if (typeof id !== 'string' || !UUID_PATTERN.test(id))
205
233
  return null;
206
234
  const { key, prefix, digest } = generateSlotlockApiKey();
207
- const [row] = await sql `
235
+ const [row] = await asChange(sql, change, (tx) => tx `
208
236
  SELECT * FROM slotlock.rotate_api_key(
209
237
  ${tenantRef}::text, ${id}::uuid, ${prefix}::text, ${digest}::bytea
210
- )`;
238
+ )`);
211
239
  return row ? { key, apiKey: apiKeyFrom(row) } : null;
212
240
  },
213
- async revoke({ tenantRef, id }) {
241
+ async revoke({ tenantRef, id, ...change }) {
214
242
  assertTenantRef(tenantRef);
215
243
  if (typeof id !== 'string' || !UUID_PATTERN.test(id))
216
244
  return null;
217
- const [row] = await sql `
218
- SELECT * FROM slotlock.revoke_api_key(${tenantRef}::text, ${id}::uuid)`;
245
+ const [row] = await asChange(sql, change, (tx) => tx `
246
+ SELECT * FROM slotlock.revoke_api_key(${tenantRef}::text, ${id}::uuid)`);
219
247
  return row ? apiKeyFrom(row) : null;
220
248
  },
221
249
  async authenticate(key) {
@@ -297,6 +325,7 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
297
325
  subject: `api_key:${identity.id}`,
298
326
  tenantRef: identity.tenantRef,
299
327
  scopes: identity.scopes,
328
+ keyId: identity.id,
300
329
  };
301
330
  }
302
331
  finally {
@@ -1,7 +1,8 @@
1
+ import { type SlotlockChangeOrigin } from './event-feed.js';
1
2
  import { z } from 'zod';
2
3
  import type { SlotlockAgentPrincipal } from './agent-server.js';
3
4
  import type { SlotlockTraceContext } from './mcp-modern.js';
4
- export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_list_pools' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook';
5
+ export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_export_resource_availability' | 'slotlock_import_resource_availability' | 'slotlock_set_resource_booking_rules' | 'slotlock_set_resource_pool' | 'slotlock_list_pools' | 'slotlock_get_booking_rule_defaults' | 'slotlock_set_tenant_booking_rules' | 'slotlock_set_pool_booking_rules' | 'slotlock_get_resource_history' | 'slotlock_get_coverage' | 'slotlock_record_coverage_failure' | 'slotlock_delete_coverage' | 'slotlock_get_reservation_history' | 'slotlock_get_tenant_event_history' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_check_in_reservation' | 'slotlock_join_waitlist' | 'slotlock_list_waitlist' | 'slotlock_leave_waitlist' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_export_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance' | 'slotlock_list_changes' | 'slotlock_create_webhook' | 'slotlock_list_webhooks' | 'slotlock_delete_webhook' | 'slotlock_enable_webhook';
5
6
  /** The scope an application operation needs, whichever operation it is. */
6
7
  export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
7
8
  export interface SlotlockApplicationInvocationContext {
@@ -10,6 +11,8 @@ export interface SlotlockApplicationInvocationContext {
10
11
  signal: AbortSignal;
11
12
  /** W3C trace context the caller sent, already validated. */
12
13
  trace?: SlotlockTraceContext;
14
+ /** The surface the call came through: `application` when the agent server dispatches it. */
15
+ origin?: SlotlockChangeOrigin;
13
16
  }
14
17
  type Handler = (context: SlotlockApplicationInvocationContext, input: Record<string, unknown>) => Promise<Record<string, unknown>>;
15
18
  /**
@@ -31,6 +34,9 @@ export interface SlotlockApplicationBackend {
31
34
  setResourceBookingRules: Handler;
32
35
  setResourcePool: Handler;
33
36
  listPools: Handler;
37
+ getBookingRuleDefaults: Handler;
38
+ setTenantBookingRules: Handler;
39
+ setPoolBookingRules: Handler;
34
40
  getResourceHistory: Handler;
35
41
  getCoverage: Handler;
36
42
  recordCoverageFailure: Handler;
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // This module holds only the contract (names, schemas, routes); it imports nothing from the agent
11
11
  // server at runtime. The store-backed implementation is createSlotlockStoreApplicationBackend.
12
- import { SLOTLOCK_CHANGE_TYPES } from './event-feed.js';
12
+ import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_TYPES } from './event-feed.js';
13
13
  import { z } from 'zod';
14
14
  import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
15
15
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
@@ -83,12 +83,16 @@ const BOOKING_RULES = z
83
83
  min_notice_minutes: z.number().int().min(0).max(525_600).optional(),
84
84
  max_advance_days: z.number().int().min(1).max(3_660).optional(),
85
85
  slot_step_minutes: z.number().int().min(1).max(1_440).optional(),
86
+ step_offset_minutes: z.number().int().min(0).max(1_439).optional(),
87
+ buffer_before_minutes: z.number().int().min(0).max(1_440).optional(),
86
88
  turnaround_minutes: z.number().int().min(0).max(1_440).optional(),
87
89
  enforce_hours: z.boolean().optional(),
88
90
  check_in_minutes: z.number().int().min(1).max(1_440).optional(),
89
91
  require_certain: z.boolean().optional(),
90
92
  })
91
93
  .strict();
94
+ /** Default booking rules for the tenant or a pool: a resource's own rules but check-in. */
95
+ const DEFAULT_BOOKING_RULES = BOOKING_RULES.omit({ check_in_minutes: true }).strict();
92
96
  const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
93
97
  const RESOURCE = z
94
98
  .object({
@@ -97,8 +101,13 @@ const RESOURCE = z
97
101
  timezone: TIMEZONE,
98
102
  /** The resource's own weekly hours; `null` when it uses the default. */
99
103
  availability: AVAILABILITY_RULES.nullable(),
100
- /** What a booking on it must satisfy; `null` when it has no rules. */
104
+ /** Its own booking rules; `null` when it has none. */
101
105
  booking_rules: BOOKING_RULES.nullable(),
106
+ /**
107
+ * What a booking on it must satisfy: the tenant's default rules, overlaid by its pool's,
108
+ * overlaid by its own; `null` when no rule applies.
109
+ */
110
+ effective_booking_rules: BOOKING_RULES.nullable(),
102
111
  /** `private` resources are never shown to agents; fixed when the resource is created. */
103
112
  visibility: VISIBILITY,
104
113
  /** When it was archived; `null` while it is in use. */
@@ -114,6 +123,10 @@ const HISTORY_ENTRY = z
114
123
  time: DATE_TIME,
115
124
  /** Who made it: an API key, an agent's subject; `null` when not recorded. */
116
125
  actor: z.string().max(200).nullable(),
126
+ /** Where it came from (mcp, a2a, rest, application, dashboard, ...); `null` when not recorded. */
127
+ origin: z.enum(SLOTLOCK_CHANGE_ORIGINS).nullable(),
128
+ /** The API key that made it, by id; `null` when no key did. */
129
+ key_id: z.string().uuid().nullable(),
117
130
  /** The record as the change left it, with `before` for a change of an existing one. */
118
131
  data: z.record(z.string(), z.unknown()),
119
132
  })
@@ -134,6 +147,10 @@ const CHANGE = z
134
147
  subject: z.string().max(500),
135
148
  time: DATE_TIME,
136
149
  datacontenttype: z.literal('application/json'),
150
+ /** Extension attribute: where the change came from, when recorded (mcp, rest, dashboard, ...). */
151
+ slotlockorigin: z.enum(SLOTLOCK_CHANGE_ORIGINS).optional(),
152
+ /** Extension attribute: the id of the API key that made it, when one did. */
153
+ slotlockkeyid: z.string().uuid().optional(),
137
154
  data: z.record(z.string(), z.unknown()),
138
155
  })
139
156
  .strict();
@@ -349,7 +366,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
349
366
  {
350
367
  name: 'slotlock_set_resource_booking_rules',
351
368
  title: "Set a resource's booking rules",
352
- description: "Set what a booking on one resource must satisfy from now on: minimum notice, how far ahead, the step starts fall on (from local midnight in the resource's time zone), the turnaround kept free after each booking, and whether bookings must lie inside its hours. `null` clears them. Holds, reservations, moves and agents' events are refused with the rule they break; searches offer only what they allow.",
369
+ description: "Set what a booking on one resource must satisfy from now on: minimum notice, how far ahead, the step starts fall on (from local midnight in the resource's time zone, offset by step_offset_minutes), the time kept free before and after each booking, and whether bookings must lie inside its hours. `null` clears them. A rule it does not set comes from its pool's or the tenant's defaults (slotlock_set_pool_booking_rules, slotlock_set_tenant_booking_rules). Holds, reservations, moves and agents' events are refused with the rule they break; searches offer only what they allow.",
353
370
  method: 'PUT',
354
371
  path: '/v1/resources/{resource_id}/booking-rules',
355
372
  input: z.object({ resource_id: RESOURCE_ID, rules: BOOKING_RULES.nullable() }).strict(),
@@ -387,10 +404,51 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
387
404
  invoke: 'listPools',
388
405
  example: {},
389
406
  },
407
+ {
408
+ name: 'slotlock_get_booking_rule_defaults',
409
+ title: 'Get default booking rules',
410
+ description: "The tenant's default booking rules, and each pool's that has them. A resource's rules as they apply (effective_booking_rules) are the tenant's defaults, overlaid by its pool's, overlaid by its own: a rule set at a narrower level wins.",
411
+ method: 'GET',
412
+ path: '/v1/booking-rules',
413
+ input: z.object({}).strict(),
414
+ output: z
415
+ .object({
416
+ tenant: DEFAULT_BOOKING_RULES.nullable(),
417
+ pools: z.array(z.object({ pool: POOL, rules: DEFAULT_BOOKING_RULES }).strict()).max(1000),
418
+ })
419
+ .strict(),
420
+ risk: { readOnly: true, destructive: false, idempotent: true },
421
+ invoke: 'getBookingRuleDefaults',
422
+ example: {},
423
+ },
424
+ {
425
+ name: 'slotlock_set_tenant_booking_rules',
426
+ title: 'Set default booking rules',
427
+ description: "Set the booking rules every resource of the tenant has unless its pool or its own rules set the same rule, from now on; `null` clears them. They take any resource rule but check_in_minutes, which only a resource sets.",
428
+ method: 'PUT',
429
+ path: '/v1/booking-rules',
430
+ input: z.object({ rules: DEFAULT_BOOKING_RULES.nullable() }).strict(),
431
+ output: z.object({ rules: DEFAULT_BOOKING_RULES.nullable() }).strict(),
432
+ risk: { readOnly: false, destructive: false, idempotent: true },
433
+ invoke: 'setTenantBookingRules',
434
+ example: { rules: { min_notice_minutes: 120, buffer_before_minutes: 15, turnaround_minutes: 15 } },
435
+ },
436
+ {
437
+ name: 'slotlock_set_pool_booking_rules',
438
+ title: "Set a pool's booking rules",
439
+ description: "Set the booking rules every member of one pool has unless its own rules set the same rule, over the tenant's defaults, from now on; `null` clears them. They take any resource rule but check_in_minutes. At most 1,000 pools have them: one more is 409 pool_rules_limit_reached.",
440
+ method: 'PUT',
441
+ path: '/v1/pools/{pool}/booking-rules',
442
+ input: z.object({ pool: POOL, rules: DEFAULT_BOOKING_RULES.nullable() }).strict(),
443
+ output: z.object({ pool: POOL, rules: DEFAULT_BOOKING_RULES.nullable() }).strict(),
444
+ risk: { readOnly: false, destructive: false, idempotent: true },
445
+ invoke: 'setPoolBookingRules',
446
+ example: { pool: 'van', rules: { slot_step_minutes: 30, step_offset_minutes: 15 } },
447
+ },
390
448
  {
391
449
  name: 'slotlock_get_resource_history',
392
450
  title: "A resource's history",
393
- description: "What happened on one of the tenant's resources, newest first: each hold, booking and event made, moved, confirmed, released or cancelled, when, by whom (the API key or agent), and for a change what it was before. Kept 30 days. Pass next_before as before for older entries.",
451
+ description: "What happened on one of the tenant's resources, newest first: each hold, booking and event made, moved, confirmed, released or cancelled, and each change to the resource itself (created, rules, pool or hours changed, archived, restored, coverage recorded or deleted), when, by whom (the actor and API key), from where (origin), and for a change what it was before. Kept 30 days. Pass next_before as before for older entries.",
394
452
  method: 'GET',
395
453
  path: '/v1/resources/{resource_id}/history',
396
454
  input: z.object({ resource_id: RESOURCE_ID, ...HISTORY_PAGE }).strict(),
@@ -699,7 +757,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
699
757
  {
700
758
  name: 'slotlock_get_coverage',
701
759
  title: "A resource's coverage and its health",
702
- description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again.",
760
+ description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id (random, made when the source is first recorded for the resource and kept until it is deleted), never the source itself.",
703
761
  method: 'GET',
704
762
  path: '/v1/resources/{resource_id}/coverage',
705
763
  input: z.object({ resource_id: RESOURCE_ID }).strict(),
@@ -709,6 +767,11 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
709
767
  .array(z
710
768
  .object({
711
769
  source: SOURCE,
770
+ /**
771
+ * How the change feed names the source (coverage.recorded, .deleted, .stale): random,
772
+ * made when it is first recorded for the resource, kept until it is deleted.
773
+ */
774
+ source_id: z.string().regex(/^src_[0-9a-f]{32}$/),
712
775
  start: DATE_TIME,
713
776
  end: DATE_TIME,
714
777
  revision: REVISION,
@@ -887,7 +950,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
887
950
  {
888
951
  name: 'slotlock_list_changes',
889
952
  title: 'List changes',
890
- description: "What changed in the tenant, oldest first, as CloudEvents: holds made, confirmed, released or expired; bookings made, moved or cancelled; calendar events created, changed or cancelled (ids, times and states, never titles or attendees). Pass next_cursor as cursor to read on; it is returned even when nothing is new, so poll with it. Only changes of transactions that have ended are listed, so following the cursor never skips one. Changes are kept 30 days: an older cursor answers 410 cursor_expired, so start again without one.",
953
+ description: "What changed in the tenant, oldest first, as CloudEvents: holds made, confirmed, released or expired; bookings made, moved or cancelled; calendar events created, changed or cancelled; resources created, changed (rules, pool, hours), archived, restored or deleted; coverage sources recorded or deleted; webhooks added, enabled or deleted; API keys created, rotated or revoked (ids, times and states, never titles, attendees, URLs or secrets). Each names where it came from (slotlockorigin) and the API key that made it (slotlockkeyid) when recorded. Pass next_cursor as cursor to read on; it is returned even when nothing is new, so poll with it. Only changes of transactions that have ended are listed, so following the cursor never skips one. Changes are kept 30 days: an older cursor answers 410 cursor_expired, so start again without one.",
891
954
  method: 'GET',
892
955
  path: '/v1/changes',
893
956
  input: z
@@ -907,7 +970,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
907
970
  input: z
908
971
  .object({
909
972
  url: z.string().url().max(2048),
910
- event_types: z.array(z.enum(SLOTLOCK_CHANGE_TYPES)).min(1).max(20).optional(),
973
+ event_types: z.array(z.enum(SLOTLOCK_CHANGE_TYPES)).min(1).max(SLOTLOCK_CHANGE_TYPES.length).optional(),
911
974
  })
912
975
  .strict(),
913
976
  output: z.object({ webhook: WEBHOOK, secret: z.string().regex(/^whsec_/) }).strict(),
package/assistant.js CHANGED
@@ -80,7 +80,8 @@ const MAX_OUTPUT_TOKENS = 1_024;
80
80
  export const SLOTLOCK_ANTHROPIC_VERSION = '2026-10-21';
81
81
  /** The assistant's records in the store, each in a transaction under the tenant's row-level security. */
82
82
  export function createSlotlockAssistantStore(store) {
83
- const by = (actor) => (actor === undefined ? {} : { actor });
83
+ // Every change here is the dashboard's, made by the person signed in (actor) when known.
84
+ const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
84
85
  return {
85
86
  getSettings: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAssistantSettings({ tenantRef })),
86
87
  putSettings: (tenantRef, settings, actor) => store.withTenant(tenantRef, (tenant) => tenant.putAssistantSettings({