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/CHANGELOG.md +194 -0
- package/README.md +159 -66
- package/SECURITY.md +26 -13
- package/SPEC.md +10 -7
- package/agent-server.d.ts +47 -4
- package/agent-server.js +492 -147
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +70 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +21 -4
- package/ddl.js +555 -7
- package/event-feed.d.ts +54 -4
- package/event-feed.js +107 -8
- package/index.d.ts +8 -7
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1309 -82
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +58 -6
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +35 -3
- package/store.js +447 -135
- package/types.d.ts +14 -1
- package/webhooks.d.ts +10 -2
- package/webhooks.js +11 -3
package/agent-store-backend.d.ts
CHANGED
|
@@ -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 {
|
package/agent-store-backend.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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' ||
|
|
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
|
|
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 {
|
package/application-api.d.ts
CHANGED
|
@@ -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;
|
package/application-api.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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({
|