slotlock 0.6.3 → 0.7.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 +467 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +40 -0
- package/assistant.js +115 -0
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1545 -64
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/agent-store-backend.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { SLOTLOCK_RESOURCE_PROFILE_FIELDS, SLOTLOCK_RECONCILE_TOLERANCE_PERCENT, slotlockResourceHasLabels, } from './resource-profile.js';
|
|
2
|
+
import { SLOTLOCK_USAGE_RETENTION_DAYS, slotlockUsageDay, slotlockUsageMonthToDate, slotlockUsageSpanDays, summarizeSlotlockUsage, } from './usage.js';
|
|
1
3
|
import { slotlockChangeFeedHead } from './event-feed.js';
|
|
2
4
|
import { slotlockCloudEvent } from './webhooks.js';
|
|
3
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { SLOTLOCK_DEFAULT_APPROVAL_MINUTES, SLOTLOCK_KEY_APPROVAL_POLICY, SLOTLOCK_MAX_APPROVAL_MINUTES, SlotlockApprovalPolicyError, } from './approvals.js';
|
|
4
7
|
import { SlotlockAgentOperationError, } from './agent-server.js';
|
|
5
8
|
import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
|
|
6
9
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
@@ -8,12 +11,40 @@ import { expandRules } from './rules.js';
|
|
|
8
11
|
import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
|
|
9
12
|
import { calendarEventRollingHorizon, } from './store.js';
|
|
10
13
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
14
|
+
import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
15
|
+
import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
|
|
16
|
+
import { slotlockPartnerKeyOutput, slotlockPrincipalPartner, } from './partners.js';
|
|
17
|
+
/** A partner's account as the partner API shows it. */
|
|
18
|
+
function partnerAccountOutput(account) {
|
|
19
|
+
return {
|
|
20
|
+
account: account.account,
|
|
21
|
+
plan: account.plan,
|
|
22
|
+
status: account.status,
|
|
23
|
+
created_at: account.createdAt.toISOString(),
|
|
24
|
+
updated_at: account.updatedAt.toISOString(),
|
|
25
|
+
...(account.resources !== undefined ? { resources: account.resources } : {}),
|
|
26
|
+
...(account.apiKeys !== undefined ? { api_keys: account.apiKeys } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
11
29
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
12
30
|
/**
|
|
13
31
|
* Writes run under READ COMMITTED whatever the role's default: tenant limits are counted under a
|
|
14
32
|
* lock that needs that level.
|
|
15
33
|
*/
|
|
16
34
|
const WRITE = { isolation: 'read committed' };
|
|
35
|
+
/**
|
|
36
|
+
* Keys a context carries only from inside this module: a request cannot set a symbol, and spreading
|
|
37
|
+
* a context (`{ ...context, operation }`) keeps them.
|
|
38
|
+
*/
|
|
39
|
+
const APPROVAL_GRANT = Symbol('slotlock.approvalGrant');
|
|
40
|
+
const OCCUPANCY_PROBE = Symbol('slotlock.occupancyProbe');
|
|
41
|
+
/** Thrown out of a dry run's transaction, with its answer, so the transaction rolls back. */
|
|
42
|
+
class DryRunRollback {
|
|
43
|
+
value;
|
|
44
|
+
constructor(value) {
|
|
45
|
+
this.value = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
17
48
|
// The UUIDs withTenant takes as a keyId (versions 1-5); any other from a custom authenticator is
|
|
18
49
|
// left out, not refused.
|
|
19
50
|
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;
|
|
@@ -35,15 +66,51 @@ async function withTenant(store, context, callback, options) {
|
|
|
35
66
|
try {
|
|
36
67
|
const actor = actorOf(context);
|
|
37
68
|
const keyId = context.principal.keyId;
|
|
38
|
-
|
|
69
|
+
// A granted approval's write: its holds give way and it is marked granted in the same
|
|
70
|
+
// transaction, so the time passes from the approval to the booking with no gap between.
|
|
71
|
+
const grant = context[APPROVAL_GRANT];
|
|
72
|
+
const write = grant
|
|
73
|
+
? async (tenantStore) => {
|
|
74
|
+
const tenantRef = context.principal.tenantRef;
|
|
75
|
+
const begun = await tenantStore.beginApprovalGrant({ tenantRef, id: grant.id });
|
|
76
|
+
if (!begun.ok) {
|
|
77
|
+
throw new SlotlockAgentOperationError(begun.code, begun.code === 'approval_not_found' ? 404 : 409);
|
|
78
|
+
}
|
|
79
|
+
const value = await callback(tenantStore);
|
|
80
|
+
const booked = value.event?.resource_id;
|
|
81
|
+
if (grant.pool !== undefined && typeof booked === 'string') {
|
|
82
|
+
await tenantStore.notePoolBooking({ tenantRef, pool: grant.pool, resourceId: booked });
|
|
83
|
+
}
|
|
84
|
+
await tenantStore.finishApprovalGrant({ tenantRef, id: grant.id, decidedBy: grant.decidedBy });
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
: callback;
|
|
88
|
+
// A dry run's transaction does everything, constraints included, then is undone: its answer
|
|
89
|
+
// is carried out of it by the rollback.
|
|
90
|
+
const probe = context[OCCUPANCY_PROBE];
|
|
91
|
+
const run = context.dryRun
|
|
92
|
+
? async (tenantStore) => {
|
|
93
|
+
const value = await write(tenantStore);
|
|
94
|
+
if (probe)
|
|
95
|
+
await probe(tenantStore);
|
|
96
|
+
throw new DryRunRollback(value);
|
|
97
|
+
}
|
|
98
|
+
: write;
|
|
99
|
+
value = await store.withTenant(context.principal.tenantRef, run, {
|
|
39
100
|
...options,
|
|
40
101
|
signal: context.signal,
|
|
41
102
|
...(actor !== undefined ? { actor } : {}),
|
|
42
103
|
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
43
104
|
...(typeof keyId === 'string' && KEY_ID.test(keyId) ? { keyId } : {}),
|
|
105
|
+
// A key limited to part of the tenant reaches only its resources, by the database's policies.
|
|
106
|
+
...(context.principal.resourceScope ? { resourceScope: context.principal.resourceScope } : {}),
|
|
44
107
|
});
|
|
45
108
|
}
|
|
46
109
|
catch (error) {
|
|
110
|
+
if (error instanceof DryRunRollback) {
|
|
111
|
+
assertActive(context);
|
|
112
|
+
return error.value;
|
|
113
|
+
}
|
|
47
114
|
const code = error?.code;
|
|
48
115
|
// A suspended tenant may read; a write it asks for is forbidden, not a server failure.
|
|
49
116
|
if (code === 'tenant_suspended')
|
|
@@ -240,15 +307,38 @@ const BOOKING_RULE_CODES = Object.freeze({
|
|
|
240
307
|
outside_hours: 'booking_rule_outside_hours',
|
|
241
308
|
uncertain: 'booking_rule_uncertain',
|
|
242
309
|
});
|
|
243
|
-
function operationError(refusal) {
|
|
310
|
+
function operationError(refusal, details) {
|
|
244
311
|
const code = typeof refusal === 'string'
|
|
245
312
|
? refusal
|
|
246
313
|
: refusal.code === 'booking_rule_violated' && refusal.rule
|
|
247
314
|
? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
|
|
248
315
|
: refusal.code;
|
|
249
316
|
const status = slotlockAgentErrorStatus(code);
|
|
250
|
-
// A gone hold (
|
|
251
|
-
return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status);
|
|
317
|
+
// A gone hold (another holder's, released or reaped) is named, so the agent holds again.
|
|
318
|
+
return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status, details);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* What an agent may be told about the booking in the way of its write: when that time is busy
|
|
322
|
+
* (as free/busy shows anyone who may read the resource), and the booking itself only when it is
|
|
323
|
+
* the agent's own hold or event, so it can release or move it. Nothing for another refusal, or for
|
|
324
|
+
* a booking the key may not see (its scope, or another tenant's).
|
|
325
|
+
*/
|
|
326
|
+
async function overlapDetails(tenantStore, context, refusal) {
|
|
327
|
+
if (refusal.code !== 'overlap' || typeof refusal.conflictingReservationId !== 'string')
|
|
328
|
+
return {};
|
|
329
|
+
const booking = await tenantStore.describeBooking({
|
|
330
|
+
tenantRef: context.principal.tenantRef,
|
|
331
|
+
id: refusal.conflictingReservationId,
|
|
332
|
+
});
|
|
333
|
+
if (!booking)
|
|
334
|
+
return {};
|
|
335
|
+
const owner = agentOwnerRef(context);
|
|
336
|
+
return {
|
|
337
|
+
busy_start: booking.start.toISOString(),
|
|
338
|
+
busy_end: booking.end.toISOString(),
|
|
339
|
+
...(booking.status === 'held' && booking.holderRef === owner ? { conflicting_hold_id: booking.id } : {}),
|
|
340
|
+
...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
|
|
341
|
+
};
|
|
252
342
|
}
|
|
253
343
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
254
344
|
const STORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -329,11 +419,22 @@ export function createSlotlockStoreChangeSource(store, sql) {
|
|
|
329
419
|
}
|
|
330
420
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
331
421
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
332
|
-
|
|
422
|
+
const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
|
|
423
|
+
if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
|
|
424
|
+
throw new RangeError(`maxApprovalMinutes must be a whole number from 1 to ${SLOTLOCK_MAX_APPROVAL_MINUTES}`);
|
|
425
|
+
}
|
|
426
|
+
const backend = {
|
|
333
427
|
async holdSlots(context, input) {
|
|
334
428
|
const slots = input.slots;
|
|
335
429
|
const key = input.idempotency_key;
|
|
336
430
|
const holderRef = agentOwnerRef(context);
|
|
431
|
+
const limits = context.principal.limits;
|
|
432
|
+
const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
|
|
433
|
+
? {
|
|
434
|
+
...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
|
|
435
|
+
...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
|
|
436
|
+
}
|
|
437
|
+
: undefined;
|
|
337
438
|
// One group per agent and key, so a retry answers with the same holds and group.
|
|
338
439
|
const groupId = uuidFromDigest(`slotlock-hold-group\0${holderRef}\0${key}`);
|
|
339
440
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -360,6 +461,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
360
461
|
end: new Date(slot.end),
|
|
361
462
|
ttlMs: input.ttl_seconds * 1_000,
|
|
362
463
|
source: 'agent-server',
|
|
464
|
+
// The key's own caps, below the server's (RFC 0004 A5).
|
|
465
|
+
...(holderCaps ? { holderCaps } : {}),
|
|
363
466
|
});
|
|
364
467
|
if (result.ok) {
|
|
365
468
|
holds.push({
|
|
@@ -419,15 +522,24 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
419
522
|
throw operationError(replay);
|
|
420
523
|
return answer(replay);
|
|
421
524
|
}
|
|
422
|
-
|
|
525
|
+
// In the pool's order (stable, least booked first, or round robin).
|
|
526
|
+
const members = await tenantStore.listPoolMembers({
|
|
527
|
+
tenantRef,
|
|
528
|
+
pool: input.pool,
|
|
529
|
+
visibility: 'shared',
|
|
530
|
+
ordered: true,
|
|
531
|
+
forBooking: true,
|
|
532
|
+
});
|
|
423
533
|
if (members.length === 0)
|
|
424
534
|
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
425
535
|
// Each member in turn, each in its own savepoint: one taken or refused by its rules is
|
|
426
536
|
// passed over; the exclusion constraint still decides.
|
|
427
537
|
for (const member of members) {
|
|
428
538
|
const result = await put(member.id);
|
|
429
|
-
if (result.ok)
|
|
539
|
+
if (result.ok) {
|
|
540
|
+
await tenantStore.notePoolBooking({ tenantRef, pool: input.pool, resourceId: member.id });
|
|
430
541
|
return answer(result);
|
|
542
|
+
}
|
|
431
543
|
if (result.code !== 'overlap' && result.code !== 'booking_rule_violated') {
|
|
432
544
|
throw operationError(result);
|
|
433
545
|
}
|
|
@@ -572,7 +684,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
572
684
|
// Thrown, so the whole transaction rolls back with the events already put.
|
|
573
685
|
if (!result.ok) {
|
|
574
686
|
const refusal = operationError(result);
|
|
575
|
-
throw new SlotlockAgentOperationError(refusal.code, refusal.status, {
|
|
687
|
+
throw new SlotlockAgentOperationError(refusal.code, refusal.status, {
|
|
688
|
+
resource_id: resourceId,
|
|
689
|
+
...(await overlapDetails(tenantStore, context, result)),
|
|
690
|
+
});
|
|
576
691
|
}
|
|
577
692
|
const created = await tenantStore.getCalendarEvent({
|
|
578
693
|
tenantRef: context.principal.tenantRef,
|
|
@@ -587,6 +702,61 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
587
702
|
return { events, replayed };
|
|
588
703
|
}, WRITE);
|
|
589
704
|
},
|
|
705
|
+
async confirmHold(context, rawInput) {
|
|
706
|
+
const input = rawInput;
|
|
707
|
+
// The holder's own hold, lapsed or live, and its resource's zone; then the booking exactly
|
|
708
|
+
// as create_event with its hold_id makes it, which judges the hold again under its lock.
|
|
709
|
+
const found = await withTenant(store, context, async (tenantStore) => {
|
|
710
|
+
const held = await tenantStore.getHolderHold({
|
|
711
|
+
tenantRef: context.principal.tenantRef,
|
|
712
|
+
holdId: input.hold_id,
|
|
713
|
+
holderRef: agentOwnerRef(context),
|
|
714
|
+
});
|
|
715
|
+
if (!held)
|
|
716
|
+
return null;
|
|
717
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, held.hold.resourceId);
|
|
718
|
+
return { ...held, timezone: resource.timezone };
|
|
719
|
+
});
|
|
720
|
+
if (!found) {
|
|
721
|
+
// Booked already: a retry answers the event this key made (the store compares the
|
|
722
|
+
// request, so another one with the same key is idempotency_conflict).
|
|
723
|
+
const booked = await withTenant(store, context, (tenantStore) => tenantStore.getCalendarEvent({
|
|
724
|
+
tenantRef: context.principal.tenantRef,
|
|
725
|
+
ownerRef: agentOwnerRef(context),
|
|
726
|
+
externalRef: agentExternalRef(context, input.idempotency_key),
|
|
727
|
+
}));
|
|
728
|
+
if (!booked)
|
|
729
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
730
|
+
return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
|
|
731
|
+
resource_id: booked.resourceId,
|
|
732
|
+
starts_at: booked.start.toISOString(),
|
|
733
|
+
ends_at: booked.end.toISOString(),
|
|
734
|
+
timezone: input.timezone ?? booked.timezone,
|
|
735
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
736
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
737
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
738
|
+
transparency: 'opaque',
|
|
739
|
+
status: 'confirmed',
|
|
740
|
+
hold_id: input.hold_id,
|
|
741
|
+
idempotency_key: input.idempotency_key,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
if (!found.live)
|
|
745
|
+
throw new SlotlockAgentOperationError('hold_expired', 409);
|
|
746
|
+
return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
|
|
747
|
+
resource_id: found.hold.resourceId,
|
|
748
|
+
starts_at: found.hold.start.toISOString(),
|
|
749
|
+
ends_at: found.hold.end.toISOString(),
|
|
750
|
+
timezone: input.timezone ?? found.timezone,
|
|
751
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
752
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
753
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
754
|
+
transparency: 'opaque',
|
|
755
|
+
status: 'confirmed',
|
|
756
|
+
hold_id: input.hold_id,
|
|
757
|
+
idempotency_key: input.idempotency_key,
|
|
758
|
+
});
|
|
759
|
+
},
|
|
590
760
|
async releaseSlots(context, input) {
|
|
591
761
|
const id = input.hold_id;
|
|
592
762
|
if (!STORE_ID_PATTERN.test(id))
|
|
@@ -596,7 +766,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
596
766
|
holderRef: agentOwnerRef(context),
|
|
597
767
|
}).then(({ released }) => ({ released })));
|
|
598
768
|
if (result.released)
|
|
599
|
-
await offerFreedTime(store, context
|
|
769
|
+
await offerFreedTime(store, context);
|
|
600
770
|
return result;
|
|
601
771
|
},
|
|
602
772
|
async describeResource(context, resourceId) {
|
|
@@ -608,28 +778,87 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
608
778
|
id: resourceId,
|
|
609
779
|
visibility: 'shared',
|
|
610
780
|
});
|
|
781
|
+
// Its profile's name when it has one, as a person reading a confirmation knows it.
|
|
611
782
|
return resource
|
|
612
|
-
? { name: resource.externalRef ?? null, timezone: resource.timezone }
|
|
783
|
+
? { name: resource.name ?? resource.externalRef ?? null, timezone: resource.timezone }
|
|
613
784
|
: null;
|
|
614
785
|
});
|
|
615
786
|
},
|
|
787
|
+
async selectResources(context, selection) {
|
|
788
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
789
|
+
let resources;
|
|
790
|
+
try {
|
|
791
|
+
// Shared and in use, as an agent may search them, in the store's stable order.
|
|
792
|
+
resources = await tenantStore.listResources({
|
|
793
|
+
tenantRef: context.principal.tenantRef,
|
|
794
|
+
visibility: 'shared',
|
|
795
|
+
limit: selection.limit + 1,
|
|
796
|
+
...(selection.kind !== undefined ? { kind: selection.kind } : {}),
|
|
797
|
+
...(selection.labels !== undefined ? { labels: selection.labels } : {}),
|
|
798
|
+
...(selection.location !== undefined ? { location: selection.location } : {}),
|
|
799
|
+
...(selection.group !== undefined ? { group: selection.group } : {}),
|
|
800
|
+
...(selection.pool !== undefined ? { pool: selection.pool } : {}),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
throw storeRefusal(error);
|
|
805
|
+
}
|
|
806
|
+
if (resources.length > selection.limit) {
|
|
807
|
+
throw new SlotlockAgentOperationError('selection_too_large', 400, { limit: String(selection.limit) });
|
|
808
|
+
}
|
|
809
|
+
return resources.map((resource) => resource.id);
|
|
810
|
+
});
|
|
811
|
+
},
|
|
616
812
|
async listResources(context, input) {
|
|
617
813
|
const cursor = parseResourceCursor(input.cursor);
|
|
814
|
+
const kind = input.kind;
|
|
815
|
+
const labels = input.labels;
|
|
816
|
+
const location = input.location;
|
|
817
|
+
const group = input.group;
|
|
818
|
+
// What a resource says about itself, for an agent to show and choose by; its attributes are
|
|
819
|
+
// the integrator's, and stay on the application API.
|
|
618
820
|
const output = (resource) => ({
|
|
619
821
|
id: resource.id,
|
|
620
822
|
external_ref: resource.externalRef,
|
|
621
823
|
timezone: resource.timezone,
|
|
622
824
|
...(resource.pool !== undefined ? { pool: resource.pool } : {}),
|
|
825
|
+
...(resource.name !== undefined ? { name: resource.name } : {}),
|
|
826
|
+
...(resource.description !== undefined
|
|
827
|
+
? {
|
|
828
|
+
description: [...resource.description].length > 500
|
|
829
|
+
? `${[...resource.description].slice(0, 500).join('')}…`
|
|
830
|
+
: resource.description,
|
|
831
|
+
}
|
|
832
|
+
: {}),
|
|
833
|
+
...(resource.kind !== undefined ? { kind: resource.kind } : {}),
|
|
834
|
+
...(resource.capacity !== undefined ? { capacity: resource.capacity } : {}),
|
|
835
|
+
...(resource.labels !== undefined ? { labels: resource.labels } : {}),
|
|
836
|
+
...(resource.location !== undefined
|
|
837
|
+
? { location: { ref: resource.location, name: resource.locationName ?? resource.location } }
|
|
838
|
+
: {}),
|
|
839
|
+
...(resource.groups !== undefined ? { groups: resource.groups } : {}),
|
|
840
|
+
...(resource.parentId !== undefined ? { part_of: resource.parentId } : {}),
|
|
623
841
|
});
|
|
624
842
|
if (typeof input.pool === 'string') {
|
|
625
|
-
return withTenant(store, context, async (tenantStore) =>
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
843
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
844
|
+
// A pool's members are filtered here: a location filter takes the locations under it.
|
|
845
|
+
const places = location === undefined
|
|
846
|
+
? null
|
|
847
|
+
: locationAndBelow(await tenantStore.listLocations({ tenantRef: context.principal.tenantRef }), location);
|
|
848
|
+
return {
|
|
849
|
+
resources: (await tenantStore.listPoolMembers({
|
|
850
|
+
tenantRef: context.principal.tenantRef,
|
|
851
|
+
pool: input.pool,
|
|
852
|
+
visibility: 'shared',
|
|
853
|
+
}))
|
|
854
|
+
.filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
|
|
855
|
+
(labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
|
|
856
|
+
(places === null || (resource.location !== undefined && places.has(resource.location))) &&
|
|
857
|
+
(group === undefined || resource.groups?.includes(group) === true))
|
|
858
|
+
.map(output),
|
|
859
|
+
next_cursor: null,
|
|
860
|
+
};
|
|
861
|
+
});
|
|
633
862
|
}
|
|
634
863
|
return withTenant(store, context, async (tenantStore) => {
|
|
635
864
|
const limit = input.limit;
|
|
@@ -638,6 +867,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
638
867
|
visibility: 'shared',
|
|
639
868
|
limit: limit + 1,
|
|
640
869
|
...(cursor !== undefined ? { after: cursor } : {}),
|
|
870
|
+
...(kind !== undefined ? { kind } : {}),
|
|
871
|
+
...(labels !== undefined ? { labels } : {}),
|
|
872
|
+
...(location !== undefined ? { location } : {}),
|
|
873
|
+
...(group !== undefined ? { group } : {}),
|
|
641
874
|
});
|
|
642
875
|
const page = resources.slice(0, limit);
|
|
643
876
|
return {
|
|
@@ -677,6 +910,53 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
677
910
|
return { resources };
|
|
678
911
|
});
|
|
679
912
|
},
|
|
913
|
+
async findNextSlots(context, input) {
|
|
914
|
+
const window = interval(input);
|
|
915
|
+
const durationMs = input.duration_minutes * 60_000;
|
|
916
|
+
const charge = busyBudget();
|
|
917
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
918
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
|
|
919
|
+
const search = bookingSearch({
|
|
920
|
+
rules: resource.effectiveBookingRules,
|
|
921
|
+
timezone: resource.timezone,
|
|
922
|
+
window,
|
|
923
|
+
now: new Date(),
|
|
924
|
+
});
|
|
925
|
+
const sources = await requiredSources(options, context, resource);
|
|
926
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
927
|
+
tenantRef: context.principal.tenantRef,
|
|
928
|
+
resourceId: input.resource_id,
|
|
929
|
+
window: search ? bookingSearchReach({ ...search, window }) : window,
|
|
930
|
+
requiredSources: [...sources],
|
|
931
|
+
...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
|
|
932
|
+
});
|
|
933
|
+
charge(freeBusy.busy.length);
|
|
934
|
+
if (freeBusy.coverage.state !== 'complete')
|
|
935
|
+
return { slots: [], certain: false };
|
|
936
|
+
if (!search)
|
|
937
|
+
return { slots: [], certain: true };
|
|
938
|
+
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
939
|
+
const windows = expandRules(rules, search.window, resource.timezone);
|
|
940
|
+
if (windows.length > MAX_REQUEST_RULE_WINDOWS)
|
|
941
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
942
|
+
const busy = bookingSearchBlocked(freeBusy.busy, search);
|
|
943
|
+
// Each next slot as a search from where the last one ended would find it: the same busy
|
|
944
|
+
// time and hours, with the hours before that end left out.
|
|
945
|
+
const slots = [];
|
|
946
|
+
let from = search.window.start.getTime();
|
|
947
|
+
while (slots.length < input.limit) {
|
|
948
|
+
const open = windows
|
|
949
|
+
.map((hours) => ({ start: new Date(Math.max(hours.start.getTime(), from)), end: hours.end }))
|
|
950
|
+
.filter((hours) => hours.end.getTime() - hours.start.getTime() >= durationMs);
|
|
951
|
+
const found = findNextAvailable({ busy, windows: open, durationMs, ...(search.align ? { align: search.align } : {}) });
|
|
952
|
+
if (!found)
|
|
953
|
+
break;
|
|
954
|
+
slots.push({ start: found.start.toISOString(), end: found.end.toISOString() });
|
|
955
|
+
from = found.end.getTime();
|
|
956
|
+
}
|
|
957
|
+
return { slots, certain: true };
|
|
958
|
+
});
|
|
959
|
+
},
|
|
680
960
|
async findNextAvailable(context, input) {
|
|
681
961
|
const window = interval(input);
|
|
682
962
|
const resourceIds = distinctResourceIds(input);
|
|
@@ -788,7 +1068,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
788
1068
|
: {}),
|
|
789
1069
|
});
|
|
790
1070
|
if (!result.ok)
|
|
791
|
-
throw operationError(result);
|
|
1071
|
+
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
792
1072
|
const created = await tenantStore.getCalendarEvent({
|
|
793
1073
|
tenantRef: context.principal.tenantRef,
|
|
794
1074
|
ownerRef: agentOwnerRef(context),
|
|
@@ -926,7 +1206,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
926
1206
|
enforceBookingRules: true,
|
|
927
1207
|
});
|
|
928
1208
|
if (!result.ok)
|
|
929
|
-
throw operationError(result);
|
|
1209
|
+
throw operationError(result, await overlapDetails(tenantStore, context, result));
|
|
930
1210
|
const updated = await tenantStore.getCalendarEvent({
|
|
931
1211
|
tenantRef: context.principal.tenantRef,
|
|
932
1212
|
ownerRef: agentOwnerRef(context),
|
|
@@ -937,7 +1217,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
937
1217
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
938
1218
|
}, WRITE);
|
|
939
1219
|
if (!moved.replayed)
|
|
940
|
-
await offerFreedTime(store, context
|
|
1220
|
+
await offerFreedTime(store, context);
|
|
941
1221
|
return moved;
|
|
942
1222
|
},
|
|
943
1223
|
async deleteEvent(context, input) {
|
|
@@ -948,13 +1228,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
948
1228
|
ownerRef: agentOwnerRef(context),
|
|
949
1229
|
externalRef,
|
|
950
1230
|
});
|
|
951
|
-
|
|
952
|
-
|
|
1231
|
+
// Gone already: a retry of a cancellation that succeeded, answered from its key, or
|
|
1232
|
+
// event_not_found from the store when there was none.
|
|
953
1233
|
const result = await tenantStore.cancelCalendarEvent({
|
|
954
1234
|
tenantRef: context.principal.tenantRef,
|
|
955
1235
|
ownerRef: agentOwnerRef(context),
|
|
956
1236
|
externalRef,
|
|
957
|
-
expectedResourceId: current.resourceId,
|
|
1237
|
+
...(current ? { expectedResourceId: current.resourceId } : {}),
|
|
958
1238
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
959
1239
|
expectedRevision: input.expected_revision,
|
|
960
1240
|
});
|
|
@@ -968,18 +1248,39 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
968
1248
|
};
|
|
969
1249
|
}, WRITE);
|
|
970
1250
|
if (!deleted.replayed)
|
|
971
|
-
await offerFreedTime(store, context
|
|
1251
|
+
await offerFreedTime(store, context);
|
|
972
1252
|
return deleted;
|
|
973
1253
|
},
|
|
974
1254
|
};
|
|
1255
|
+
return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
|
|
975
1256
|
}
|
|
976
1257
|
function storeRefusal(error) {
|
|
977
1258
|
const code = error?.code;
|
|
978
1259
|
if (typeof code !== 'string')
|
|
979
1260
|
return error;
|
|
1261
|
+
if (code === 'invalid_resource_profile' || code === 'invalid_location' || code === 'invalid_group') {
|
|
1262
|
+
// Which field, so a client can say what to change.
|
|
1263
|
+
const field = error.field;
|
|
1264
|
+
return new SlotlockAgentOperationError(code, 400, typeof field === 'string' ? { field } : undefined);
|
|
1265
|
+
}
|
|
1266
|
+
if (code === 'location_not_found' || code === 'group_not_found' || code === 'resource_not_found' || code === 'parent_not_found') {
|
|
1267
|
+
return new SlotlockAgentOperationError(code, 404);
|
|
1268
|
+
}
|
|
1269
|
+
if (code === 'location_in_use' ||
|
|
1270
|
+
code === 'location_too_deep' ||
|
|
1271
|
+
code === 'location_cycle' ||
|
|
1272
|
+
code === 'location_limit_reached' ||
|
|
1273
|
+
code === 'group_limit_reached' ||
|
|
1274
|
+
code === 'resource_group_limit_reached') {
|
|
1275
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
1276
|
+
}
|
|
980
1277
|
if (code === 'resource_limit_reached' ||
|
|
981
1278
|
code === 'resource_visibility_conflict' ||
|
|
982
|
-
code === 'pool_rules_limit_reached'
|
|
1279
|
+
code === 'pool_rules_limit_reached' ||
|
|
1280
|
+
code === 'pool_order_limit_reached' ||
|
|
1281
|
+
code === 'composite_too_deep' ||
|
|
1282
|
+
code === 'composite_parts_limit' ||
|
|
1283
|
+
code === 'composite_busy') {
|
|
983
1284
|
return new SlotlockAgentOperationError(code, 409);
|
|
984
1285
|
}
|
|
985
1286
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
@@ -987,6 +1288,88 @@ function storeRefusal(error) {
|
|
|
987
1288
|
}
|
|
988
1289
|
return error;
|
|
989
1290
|
}
|
|
1291
|
+
/** A key's limits as the API takes them (snake_case), as the key store takes them. */
|
|
1292
|
+
function keyLimitsFromInput(value) {
|
|
1293
|
+
const given = value;
|
|
1294
|
+
return {
|
|
1295
|
+
...(given.max_holds !== undefined ? { maxHolds: given.max_holds } : {}),
|
|
1296
|
+
...(given.max_held_hours !== undefined ? { maxHeldHours: given.max_held_hours } : {}),
|
|
1297
|
+
...(given.requests_per_minute !== undefined ? { requestsPerMinute: given.requests_per_minute } : {}),
|
|
1298
|
+
...(given.requires_approval === true ? { requiresApproval: true } : {}),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/** A sandbox refusal as the API answers it: 403 for a live tenant, 409 for one not empty. */
|
|
1302
|
+
function sandboxRefusal(error) {
|
|
1303
|
+
const code = error?.code;
|
|
1304
|
+
if (code === 'sandbox_test_only')
|
|
1305
|
+
return new SlotlockAgentOperationError(code, 403);
|
|
1306
|
+
if (code === 'sandbox_not_empty')
|
|
1307
|
+
return new SlotlockAgentOperationError(code, 409);
|
|
1308
|
+
return error;
|
|
1309
|
+
}
|
|
1310
|
+
/** An approval decision refused: 404 for none, 409 for one decided or past its wait. */
|
|
1311
|
+
function approvalRefusal(code) {
|
|
1312
|
+
return new SlotlockAgentOperationError(code, code === 'approval_not_found' ? 404 : 409);
|
|
1313
|
+
}
|
|
1314
|
+
function approvalPolicyOutput(policy) {
|
|
1315
|
+
const applies = policy.appliesTo;
|
|
1316
|
+
const conditions = policy.conditions;
|
|
1317
|
+
return {
|
|
1318
|
+
policy_ref: policy.ref,
|
|
1319
|
+
name: policy.name,
|
|
1320
|
+
applies_to: {
|
|
1321
|
+
...(applies.resourceIds ? { resource_ids: applies.resourceIds } : {}),
|
|
1322
|
+
...(applies.pools ? { pools: applies.pools } : {}),
|
|
1323
|
+
...(applies.locations ? { locations: applies.locations } : {}),
|
|
1324
|
+
...(applies.groups ? { groups: applies.groups } : {}),
|
|
1325
|
+
},
|
|
1326
|
+
conditions: {
|
|
1327
|
+
...(conditions.longerThanMinutes !== undefined ? { longer_than_minutes: conditions.longerThanMinutes } : {}),
|
|
1328
|
+
...(conditions.outsideHours !== undefined ? { outside_hours: conditions.outsideHours } : {}),
|
|
1329
|
+
...(conditions.kinds ? { kinds: conditions.kinds } : {}),
|
|
1330
|
+
},
|
|
1331
|
+
outcome: policy.outcome,
|
|
1332
|
+
approval_ttl_minutes: policy.approvalTtlMinutes,
|
|
1333
|
+
lead_minutes: policy.leadMinutes,
|
|
1334
|
+
created_at: policy.createdAt.toISOString(),
|
|
1335
|
+
updated_at: policy.updatedAt.toISOString(),
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function approvalAppliesTo(value) {
|
|
1339
|
+
const given = value;
|
|
1340
|
+
return {
|
|
1341
|
+
...(given.resource_ids ? { resourceIds: given.resource_ids } : {}),
|
|
1342
|
+
...(given.pools ? { pools: given.pools } : {}),
|
|
1343
|
+
...(given.locations ? { locations: given.locations } : {}),
|
|
1344
|
+
...(given.groups ? { groups: given.groups } : {}),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function approvalConditions(value) {
|
|
1348
|
+
const given = value;
|
|
1349
|
+
return {
|
|
1350
|
+
...(given.longer_than_minutes !== undefined ? { longerThanMinutes: given.longer_than_minutes } : {}),
|
|
1351
|
+
...(given.outside_hours !== undefined ? { outsideHours: given.outside_hours } : {}),
|
|
1352
|
+
...(given.kinds ? { kinds: given.kinds } : {}),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function approvalOutput(approval) {
|
|
1356
|
+
return {
|
|
1357
|
+
approval_id: approval.id,
|
|
1358
|
+
policy_ref: approval.policyRef,
|
|
1359
|
+
status: approval.status,
|
|
1360
|
+
operation: approval.operation,
|
|
1361
|
+
request: approval.request,
|
|
1362
|
+
requested_by: approval.requestedBy,
|
|
1363
|
+
resource_ids: approval.resourceIds,
|
|
1364
|
+
starts_at: approval.start.toISOString(),
|
|
1365
|
+
ends_at: approval.end.toISOString(),
|
|
1366
|
+
expires_at: approval.expiresAt.toISOString(),
|
|
1367
|
+
decided_by: approval.decidedBy,
|
|
1368
|
+
decided_at: approval.decidedAt?.toISOString() ?? null,
|
|
1369
|
+
reason: approval.reason,
|
|
1370
|
+
created_at: approval.createdAt.toISOString(),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
990
1373
|
function applicationRefusal(code) {
|
|
991
1374
|
if (code === 'resource_tenant_mismatch') {
|
|
992
1375
|
return new SlotlockAgentOperationError('resource_not_found', 404);
|
|
@@ -1013,11 +1396,380 @@ function waitlistOutput(entry) {
|
|
|
1013
1396
|
created_at: entry.createdAt.toISOString(),
|
|
1014
1397
|
};
|
|
1015
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* The policy a key whose bookings wait for a person (`requiresApproval`, RFC 0004 A5) is held by:
|
|
1401
|
+
* every resource, a day's wait. A tenant cannot name a policy of its own `api-key`.
|
|
1402
|
+
*/
|
|
1403
|
+
function keyApprovalPolicy() {
|
|
1404
|
+
return {
|
|
1405
|
+
ref: SLOTLOCK_KEY_APPROVAL_POLICY,
|
|
1406
|
+
name: 'Bookings by this API key',
|
|
1407
|
+
appliesTo: {},
|
|
1408
|
+
conditions: {},
|
|
1409
|
+
outcome: 'require_approval',
|
|
1410
|
+
approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
|
|
1411
|
+
leadMinutes: 0,
|
|
1412
|
+
createdAt: new Date(0),
|
|
1413
|
+
updatedAt: new Date(0),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/** A JSON value with its object keys sorted, so two requests compare by content. */
|
|
1417
|
+
function canonical(value) {
|
|
1418
|
+
if (Array.isArray(value))
|
|
1419
|
+
return value.map(canonical);
|
|
1420
|
+
if (value !== null && typeof value === 'object') {
|
|
1421
|
+
return Object.fromEntries(Object.keys(value)
|
|
1422
|
+
.sort()
|
|
1423
|
+
.map((key) => [key, canonical(value[key])]));
|
|
1424
|
+
}
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
/** Each interval of `wanted` less the parts `kept` already covers on the same resource. */
|
|
1428
|
+
function uncovered(wanted, kept) {
|
|
1429
|
+
const pieces = [];
|
|
1430
|
+
for (const want of wanted) {
|
|
1431
|
+
let open = [{ start: want.start, end: want.end }];
|
|
1432
|
+
for (const own of kept) {
|
|
1433
|
+
if (own.resourceId !== want.resourceId)
|
|
1434
|
+
continue;
|
|
1435
|
+
open = open.flatMap((piece) => {
|
|
1436
|
+
if (own.end <= piece.start || own.start >= piece.end)
|
|
1437
|
+
return [piece];
|
|
1438
|
+
return [
|
|
1439
|
+
...(own.start > piece.start ? [{ start: piece.start, end: own.start }] : []),
|
|
1440
|
+
...(own.end < piece.end ? [{ start: own.end, end: piece.end }] : []),
|
|
1441
|
+
];
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
pieces.push(...open.map((piece) => ({ ...piece, resourceId: want.resourceId })));
|
|
1445
|
+
}
|
|
1446
|
+
return pieces;
|
|
1447
|
+
}
|
|
1448
|
+
/** How a request already put to a person answers its retry: waiting, declined or expired. */
|
|
1449
|
+
function approvalAnswer(approval) {
|
|
1450
|
+
const id = { approval_id: approval.id, policy_ref: approval.policyRef };
|
|
1451
|
+
if (approval.status === 'declined') {
|
|
1452
|
+
return new SlotlockAgentOperationError('approval_declined', 403, {
|
|
1453
|
+
...id,
|
|
1454
|
+
...(approval.reason ? { reason: approval.reason.slice(0, 200) } : {}),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
if (approval.status === 'expired' || approval.expiresAt.getTime() <= Date.now()) {
|
|
1458
|
+
return new SlotlockAgentOperationError('approval_expired', 409, id);
|
|
1459
|
+
}
|
|
1460
|
+
return new SlotlockAgentOperationError('approval_pending', 409, {
|
|
1461
|
+
...id,
|
|
1462
|
+
expires_at: approval.expiresAt.toISOString(),
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Approval policies (RFC 0004 A4) in front of the backend's writes. A write a policy covers is
|
|
1467
|
+
* tried first as a dry run, so it meets every check and the database's constraints as it would;
|
|
1468
|
+
* a `refuse` policy then refuses it, and a `require_approval` one holds the exact time it would
|
|
1469
|
+
* occupy for the approval and answers `approval_pending`. A retry with the same idempotency key
|
|
1470
|
+
* answers as the request stands, and once granted, with the booking itself. Tenants with no policy
|
|
1471
|
+
* pay one indexed read per write.
|
|
1472
|
+
*/
|
|
1473
|
+
function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
|
|
1474
|
+
const requestKeyOf = (context, stored, key) => createHash('sha256')
|
|
1475
|
+
.update(JSON.stringify(['slotlock-approval', context.principal.tenantRef, agentOwnerRef(context), stored, key]))
|
|
1476
|
+
.digest('hex');
|
|
1477
|
+
/** Whether [start, end) on the resource falls outside its bookable hours. */
|
|
1478
|
+
async function outsideHours(tenantStore, context, resourceId, start, end) {
|
|
1479
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
1480
|
+
const hours = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
1481
|
+
const windows = mergeIntervals(expandRules(hours, { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) }, resource.timezone));
|
|
1482
|
+
return !windows.some((window) => window.start <= start && window.end >= end);
|
|
1483
|
+
}
|
|
1484
|
+
/** The write's ask and the policies that may cover it, or null when none can. */
|
|
1485
|
+
async function plan(tenantStore, context, operation, input) {
|
|
1486
|
+
const tenantRef = context.principal.tenantRef;
|
|
1487
|
+
const ownerRef = agentOwnerRef(context);
|
|
1488
|
+
const key = input.idempotency_key;
|
|
1489
|
+
// A key whose bookings all wait for a person (RFC 0004 A5) is a policy of its own.
|
|
1490
|
+
const keyPolicy = context.principal.limits?.requiresApproval === true ? keyApprovalPolicy() : undefined;
|
|
1491
|
+
// Most tenants have none: one small indexed read, and nothing else looked up.
|
|
1492
|
+
if (!keyPolicy && (await tenantStore.listApprovalPolicies({ tenantRef })).length === 0)
|
|
1493
|
+
return null;
|
|
1494
|
+
let asked;
|
|
1495
|
+
if (operation === 'slotlock_create_event') {
|
|
1496
|
+
asked = {
|
|
1497
|
+
kind: 'single',
|
|
1498
|
+
stored: 'slotlock_create_event',
|
|
1499
|
+
resourceIds: [input.resource_id],
|
|
1500
|
+
externalRefs: [agentExternalRef(context, key)],
|
|
1501
|
+
start: new Date(input.starts_at),
|
|
1502
|
+
end: new Date(input.ends_at),
|
|
1503
|
+
series: typeof input.recurrence_rule === 'string',
|
|
1504
|
+
before: [],
|
|
1505
|
+
...(typeof input.hold_id === 'string' ? { consumeHold: { holdId: input.hold_id, holderRef: ownerRef } } : {}),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
else if (operation === 'slotlock_book_pool') {
|
|
1509
|
+
const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
|
|
1510
|
+
if (members.length === 0)
|
|
1511
|
+
return null;
|
|
1512
|
+
asked = {
|
|
1513
|
+
kind: 'pool',
|
|
1514
|
+
stored: 'slotlock_create_event',
|
|
1515
|
+
resourceIds: members.map((member) => member.id),
|
|
1516
|
+
externalRefs: [agentExternalRef(context, key)],
|
|
1517
|
+
start: new Date(input.starts_at),
|
|
1518
|
+
end: new Date(input.ends_at),
|
|
1519
|
+
series: false,
|
|
1520
|
+
before: [],
|
|
1521
|
+
pool: input.pool,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
else if (operation === 'slotlock_book_together') {
|
|
1525
|
+
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
1526
|
+
asked = {
|
|
1527
|
+
kind: 'bundle',
|
|
1528
|
+
stored: 'slotlock_book_together',
|
|
1529
|
+
resourceIds,
|
|
1530
|
+
externalRefs: resourceIds.map((resourceId) => agentExternalRef(context, `${key}\0${resourceId}`)),
|
|
1531
|
+
start: new Date(input.starts_at),
|
|
1532
|
+
end: new Date(input.ends_at),
|
|
1533
|
+
series: false,
|
|
1534
|
+
before: [],
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
|
|
1539
|
+
if (!current)
|
|
1540
|
+
return null;
|
|
1541
|
+
const resourceId = input.resource_id ?? current.resourceId;
|
|
1542
|
+
const start = input.starts_at ? new Date(input.starts_at) : current.start;
|
|
1543
|
+
const end = input.ends_at ? new Date(input.ends_at) : current.end;
|
|
1544
|
+
// Only a move to another time or resource is a booking a policy can be about.
|
|
1545
|
+
if (resourceId === current.resourceId && start.getTime() === current.start.getTime() && end.getTime() === current.end.getTime()) {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
asked = {
|
|
1549
|
+
kind: 'move',
|
|
1550
|
+
stored: 'slotlock_update_event',
|
|
1551
|
+
resourceIds: [resourceId],
|
|
1552
|
+
externalRefs: [current.externalRef],
|
|
1553
|
+
start,
|
|
1554
|
+
end,
|
|
1555
|
+
series: input.recurrence_rule !== undefined ? typeof input.recurrence_rule === 'string' : current.recurrence !== undefined,
|
|
1556
|
+
before: await tenantStore.eventOccupancy({ tenantRef, ownerRef, externalRefs: [current.externalRef] }),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
|
|
1560
|
+
// What the ask alone decides; hours wait for the resource the write takes.
|
|
1561
|
+
const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) => (policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
|
|
1562
|
+
(policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
|
|
1563
|
+
if (policies.length === 0 && !keyPolicy)
|
|
1564
|
+
return null;
|
|
1565
|
+
return { ...asked, policies, ...(keyPolicy ? { keyPolicy } : {}) };
|
|
1566
|
+
}
|
|
1567
|
+
function gated(operation, method) {
|
|
1568
|
+
return async function (context, input) {
|
|
1569
|
+
// The grant itself, or a write a person already approved and its retries.
|
|
1570
|
+
if (context[APPROVAL_GRANT])
|
|
1571
|
+
return method.call(this, context, input);
|
|
1572
|
+
const tenantRef = context.principal.tenantRef;
|
|
1573
|
+
const stored = operation === 'slotlock_book_together' || operation === 'slotlock_update_event' ? operation : 'slotlock_create_event';
|
|
1574
|
+
const requestKey = requestKeyOf(context, stored, input.idempotency_key);
|
|
1575
|
+
const asked = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
1576
|
+
const prior = await tenantStore.findApproval({ tenantRef, requestKey });
|
|
1577
|
+
if (prior)
|
|
1578
|
+
return { prior };
|
|
1579
|
+
return { plan: await plan(tenantStore, context, operation, input) };
|
|
1580
|
+
});
|
|
1581
|
+
if (asked.prior) {
|
|
1582
|
+
const { hold_id: _held, ...request } = input;
|
|
1583
|
+
// Granted: the booking was made with this key, so the write answers it as a replay (and
|
|
1584
|
+
// the store refuses another request with it). Without the hold the approval took over,
|
|
1585
|
+
// as the grant made it.
|
|
1586
|
+
if (asked.prior.status === 'granted')
|
|
1587
|
+
return method.call(this, context, request);
|
|
1588
|
+
const kept = { ...asked.prior.request };
|
|
1589
|
+
// A pool booking is kept as the member it would take.
|
|
1590
|
+
if (operation === 'slotlock_book_pool')
|
|
1591
|
+
delete kept.resource_id;
|
|
1592
|
+
if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
|
|
1593
|
+
throw new SlotlockAgentOperationError('idempotency_conflict', 409);
|
|
1594
|
+
}
|
|
1595
|
+
throw approvalAnswer(asked.prior);
|
|
1596
|
+
}
|
|
1597
|
+
const ask = asked.plan;
|
|
1598
|
+
if (!ask)
|
|
1599
|
+
return method.call(this, context, input);
|
|
1600
|
+
// Tried as a dry run, reading what it would occupy before it is undone: a write that would be
|
|
1601
|
+
// refused is refused as it would be.
|
|
1602
|
+
let occupied = [];
|
|
1603
|
+
const probed = await method.call(this, {
|
|
1604
|
+
...context,
|
|
1605
|
+
dryRun: true,
|
|
1606
|
+
[OCCUPANCY_PROBE]: async (tenantStore) => {
|
|
1607
|
+
occupied = await tenantStore.eventOccupancy({
|
|
1608
|
+
tenantRef,
|
|
1609
|
+
ownerRef: agentOwnerRef(context),
|
|
1610
|
+
externalRefs: ask.externalRefs,
|
|
1611
|
+
});
|
|
1612
|
+
},
|
|
1613
|
+
}, input);
|
|
1614
|
+
// Made already (before the policy, say), or takes no time (a transparent event): nothing to hold.
|
|
1615
|
+
if (probed.replayed === true || occupied.length === 0) {
|
|
1616
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
1617
|
+
}
|
|
1618
|
+
const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
|
|
1619
|
+
const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
|
|
1620
|
+
// A pool's policies are those of the member the write would take, and its pool.
|
|
1621
|
+
const candidates = [
|
|
1622
|
+
...(ask.pool
|
|
1623
|
+
? (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: written })).filter((policy) => ask.policies.some((known) => known.ref === policy.ref))
|
|
1624
|
+
: ask.policies),
|
|
1625
|
+
...(ask.keyPolicy ? [ask.keyPolicy] : []),
|
|
1626
|
+
];
|
|
1627
|
+
const applying = [];
|
|
1628
|
+
for (const policy of candidates) {
|
|
1629
|
+
if (policy.conditions.outsideHours === true) {
|
|
1630
|
+
let outside = false;
|
|
1631
|
+
for (const resourceId of written) {
|
|
1632
|
+
if (await outsideHours(tenantStore, context, resourceId, ask.start, ask.end))
|
|
1633
|
+
outside = true;
|
|
1634
|
+
}
|
|
1635
|
+
if (!outside)
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
applying.push(policy);
|
|
1639
|
+
}
|
|
1640
|
+
return applying;
|
|
1641
|
+
});
|
|
1642
|
+
if (decided.length === 0)
|
|
1643
|
+
return context.dryRun ? probed : method.call(this, context, input);
|
|
1644
|
+
const refusing = decided.find((policy) => policy.outcome === 'refuse');
|
|
1645
|
+
if (refusing)
|
|
1646
|
+
throw new SlotlockAgentOperationError('approval_policy_refused', 403, { policy_ref: refusing.ref });
|
|
1647
|
+
// The strictest wait: the earliest a person must decide by, under each policy's own wait, its
|
|
1648
|
+
// lead before the start, and the server's ceiling.
|
|
1649
|
+
const now = Date.now();
|
|
1650
|
+
const deadline = (policy) => Math.min(now + Math.min(policy.approvalTtlMinutes, maxApprovalMinutes) * 60_000, ask.start.getTime() - policy.leadMinutes * 60_000);
|
|
1651
|
+
const policy = [...decided].sort((left, right) => deadline(left) - deadline(right) || left.ref.localeCompare(right.ref))[0];
|
|
1652
|
+
const expiresAt = new Date(deadline(policy));
|
|
1653
|
+
if (ask.series || occupied.some((occupation) => occupation.recurring)) {
|
|
1654
|
+
throw new SlotlockAgentOperationError('approval_series_unsupported', 409, { policy_ref: policy.ref });
|
|
1655
|
+
}
|
|
1656
|
+
if (expiresAt.getTime() < now + 60_000) {
|
|
1657
|
+
throw new SlotlockAgentOperationError('approval_too_late', 409, { policy_ref: policy.ref });
|
|
1658
|
+
}
|
|
1659
|
+
if (context.dryRun) {
|
|
1660
|
+
throw new SlotlockAgentOperationError('approval_required', 409, {
|
|
1661
|
+
policy_ref: policy.ref,
|
|
1662
|
+
expires_at: expiresAt.toISOString(),
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
const holds = uncovered(occupied, ask.before);
|
|
1666
|
+
if (holds.length === 0)
|
|
1667
|
+
return method.call(this, context, input);
|
|
1668
|
+
const chosen = ask.pool ? written[0] : undefined;
|
|
1669
|
+
const { hold_id: _hold, ...request } = input;
|
|
1670
|
+
const principal = context.principal;
|
|
1671
|
+
const result = await withTenant(store, context, (tenantStore) => tenantStore.requestApproval({
|
|
1672
|
+
tenantRef,
|
|
1673
|
+
policyRef: policy.ref,
|
|
1674
|
+
operation: ask.stored,
|
|
1675
|
+
request: canonical(chosen ? { ...request, resource_id: chosen } : request),
|
|
1676
|
+
requester: {
|
|
1677
|
+
subject: principal.subject,
|
|
1678
|
+
...(principal.scopes ? { scopes: [...principal.scopes] } : {}),
|
|
1679
|
+
...(principal.keyId !== undefined ? { key_id: principal.keyId } : {}),
|
|
1680
|
+
...(principal.resourceScope
|
|
1681
|
+
? { resource_scope: { locations: [...principal.resourceScope.locations], groups: [...principal.resourceScope.groups] } }
|
|
1682
|
+
: {}),
|
|
1683
|
+
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
1684
|
+
...(ask.pool !== undefined ? { pool: ask.pool } : {}),
|
|
1685
|
+
},
|
|
1686
|
+
requesterRef: principal.subject,
|
|
1687
|
+
requestKey,
|
|
1688
|
+
holds,
|
|
1689
|
+
start: ask.start,
|
|
1690
|
+
end: ask.end,
|
|
1691
|
+
expiresAt,
|
|
1692
|
+
...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
|
|
1693
|
+
}), WRITE);
|
|
1694
|
+
if (!result.ok) {
|
|
1695
|
+
if (result.code === 'approval_limit_reached') {
|
|
1696
|
+
throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
|
|
1697
|
+
}
|
|
1698
|
+
throw new SlotlockAgentOperationError(result.code, 409);
|
|
1699
|
+
}
|
|
1700
|
+
throw approvalAnswer(result.approval);
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
const wrapped = { ...backend };
|
|
1704
|
+
wrapped.createEvent = gated('slotlock_create_event', backend.createEvent);
|
|
1705
|
+
wrapped.updateEvent = gated('slotlock_update_event', backend.updateEvent);
|
|
1706
|
+
if (backend.bookPool)
|
|
1707
|
+
wrapped.bookPool = gated('slotlock_book_pool', backend.bookPool);
|
|
1708
|
+
if (backend.bookTogether)
|
|
1709
|
+
wrapped.bookTogether = gated('slotlock_book_together', backend.bookTogether);
|
|
1710
|
+
if (backend.confirmHold) {
|
|
1711
|
+
const confirm = backend.confirmHold;
|
|
1712
|
+
// A confirm already put to a person answers as it stands, before its hold (now the
|
|
1713
|
+
// approval's) is looked for; otherwise it books through createEvent, policies and all.
|
|
1714
|
+
wrapped.confirmHold = async function (context, input) {
|
|
1715
|
+
if (!context[APPROVAL_GRANT]) {
|
|
1716
|
+
const requestKey = requestKeyOf(context, 'slotlock_create_event', input.idempotency_key);
|
|
1717
|
+
const prior = await withTenant(store, { ...context, dryRun: false }, (tenantStore) => tenantStore.findApproval({ tenantRef: context.principal.tenantRef, requestKey }));
|
|
1718
|
+
if (prior && prior.status !== 'granted')
|
|
1719
|
+
throw approvalAnswer(prior);
|
|
1720
|
+
}
|
|
1721
|
+
return confirm.call(this, context, input);
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
return wrapped;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Run a granted approval's write as the agent that asked for it, in one transaction with the grant:
|
|
1728
|
+
* its holds give way, the write is made, and the approval is marked granted.
|
|
1729
|
+
*/
|
|
1730
|
+
export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
|
|
1731
|
+
const requester = approval.requester;
|
|
1732
|
+
if (typeof requester.subject !== 'string')
|
|
1733
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
1734
|
+
const scope = requester.resource_scope;
|
|
1735
|
+
const context = {
|
|
1736
|
+
principal: {
|
|
1737
|
+
subject: requester.subject,
|
|
1738
|
+
tenantRef: grant.tenantRef,
|
|
1739
|
+
...(Array.isArray(requester.scopes) ? { scopes: requester.scopes } : {}),
|
|
1740
|
+
...(typeof requester.key_id === 'string' ? { keyId: requester.key_id } : {}),
|
|
1741
|
+
...(scope && Array.isArray(scope.locations) && Array.isArray(scope.groups)
|
|
1742
|
+
? { resourceScope: { locations: scope.locations, groups: scope.groups } }
|
|
1743
|
+
: {}),
|
|
1744
|
+
},
|
|
1745
|
+
operation: approval.operation,
|
|
1746
|
+
signal: grant.signal,
|
|
1747
|
+
...(grant.origin !== undefined ? { origin: grant.origin } : {}),
|
|
1748
|
+
...(grant.dryRun ? { dryRun: true } : {}),
|
|
1749
|
+
[APPROVAL_GRANT]: {
|
|
1750
|
+
id: approval.id,
|
|
1751
|
+
decidedBy: grant.decidedBy,
|
|
1752
|
+
...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
|
|
1753
|
+
},
|
|
1754
|
+
};
|
|
1755
|
+
const method = approval.operation === 'slotlock_book_together'
|
|
1756
|
+
? agentBackend.bookTogether
|
|
1757
|
+
: approval.operation === 'slotlock_update_event'
|
|
1758
|
+
? agentBackend.updateEvent
|
|
1759
|
+
: agentBackend.createEvent;
|
|
1760
|
+
if (!method)
|
|
1761
|
+
throw new SlotlockAgentOperationError('approvals_unavailable', 409);
|
|
1762
|
+
return method.call(agentBackend, context, approval.request);
|
|
1763
|
+
}
|
|
1016
1764
|
/**
|
|
1017
1765
|
* Offer what a cancel or release just freed to the waitlist now. Best effort, after commit:
|
|
1018
1766
|
* maintenance offers whatever this misses.
|
|
1019
1767
|
*/
|
|
1020
|
-
async function offerFreedTime(store,
|
|
1768
|
+
async function offerFreedTime(store, context) {
|
|
1769
|
+
// A dry run freed nothing.
|
|
1770
|
+
if (context.dryRun)
|
|
1771
|
+
return;
|
|
1772
|
+
const tenantRef = context.principal.tenantRef;
|
|
1021
1773
|
try {
|
|
1022
1774
|
await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
|
|
1023
1775
|
origin: 'maintenance',
|
|
@@ -1039,6 +1791,14 @@ function webhookOutput(endpoint) {
|
|
|
1039
1791
|
last_error: endpoint.lastError,
|
|
1040
1792
|
disabled_at: endpoint.disabledAt?.toISOString() ?? null,
|
|
1041
1793
|
disabled_reason: endpoint.disabledReason,
|
|
1794
|
+
filter: endpoint.filter
|
|
1795
|
+
? {
|
|
1796
|
+
...(endpoint.filter.resourceIds ? { resource_ids: endpoint.filter.resourceIds } : {}),
|
|
1797
|
+
...(endpoint.filter.pools ? { pools: endpoint.filter.pools } : {}),
|
|
1798
|
+
...(endpoint.filter.locations ? { locations: endpoint.filter.locations } : {}),
|
|
1799
|
+
...(endpoint.filter.groups ? { groups: endpoint.filter.groups } : {}),
|
|
1800
|
+
}
|
|
1801
|
+
: null,
|
|
1042
1802
|
};
|
|
1043
1803
|
}
|
|
1044
1804
|
function rulesOutput(rules) {
|
|
@@ -1117,8 +1877,53 @@ function resourceOutput(resource) {
|
|
|
1117
1877
|
visibility: resource.visibility ?? 'shared',
|
|
1118
1878
|
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
1119
1879
|
pool: resource.pool ?? null,
|
|
1880
|
+
name: resource.name ?? null,
|
|
1881
|
+
description: resource.description ?? null,
|
|
1882
|
+
kind: resource.kind ?? 'other',
|
|
1883
|
+
capacity: resource.capacity ?? null,
|
|
1884
|
+
labels: resource.labels ?? [],
|
|
1885
|
+
attributes: resource.attributes ?? {},
|
|
1886
|
+
location: resource.location ?? null,
|
|
1887
|
+
groups: resource.groups ?? [],
|
|
1888
|
+
parent_id: resource.parentId ?? null,
|
|
1889
|
+
part_index: resource.partIndex ?? null,
|
|
1120
1890
|
};
|
|
1121
1891
|
}
|
|
1892
|
+
function locationOutput(location) {
|
|
1893
|
+
return {
|
|
1894
|
+
ref: location.ref,
|
|
1895
|
+
name: location.name,
|
|
1896
|
+
parent_ref: location.parentRef,
|
|
1897
|
+
timezone: location.timezone,
|
|
1898
|
+
resources: location.resources,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function groupOutput(group) {
|
|
1902
|
+
return { ref: group.ref, name: group.name, description: group.description, members: group.members };
|
|
1903
|
+
}
|
|
1904
|
+
/** The references of a location and every location under it, from the tenant's whole tree. */
|
|
1905
|
+
function locationAndBelow(locations, ref) {
|
|
1906
|
+
const found = new Set([ref]);
|
|
1907
|
+
for (let grew = true; grew;) {
|
|
1908
|
+
grew = false;
|
|
1909
|
+
for (const location of locations) {
|
|
1910
|
+
if (location.parentRef !== null && found.has(location.parentRef) && !found.has(location.ref)) {
|
|
1911
|
+
found.add(location.ref);
|
|
1912
|
+
grew = true;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return found;
|
|
1917
|
+
}
|
|
1918
|
+
/** The profile fields of an operation's input, as the store takes them; absent ones left out. */
|
|
1919
|
+
function profileInput(input) {
|
|
1920
|
+
const profile = {};
|
|
1921
|
+
for (const field of SLOTLOCK_RESOURCE_PROFILE_FIELDS) {
|
|
1922
|
+
if (input[field] !== undefined)
|
|
1923
|
+
profile[field] = input[field];
|
|
1924
|
+
}
|
|
1925
|
+
return profile;
|
|
1926
|
+
}
|
|
1122
1927
|
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
1123
1928
|
function tenantEventOutput(event) {
|
|
1124
1929
|
return {
|
|
@@ -1218,6 +2023,23 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1218
2023
|
};
|
|
1219
2024
|
});
|
|
1220
2025
|
}
|
|
2026
|
+
/** The partner store and the partner a partner key names; any other caller is forbidden. */
|
|
2027
|
+
function partnerOf(context) {
|
|
2028
|
+
assertActive(context);
|
|
2029
|
+
const partners = options.partners;
|
|
2030
|
+
if (!partners)
|
|
2031
|
+
throw new SlotlockAgentOperationError('partner_api_not_served', 404);
|
|
2032
|
+
return { partners, partner: slotlockPrincipalPartner(context.principal) };
|
|
2033
|
+
}
|
|
2034
|
+
/** As partnerOf, with the account's tenant: 404 account_not_found for one the partner lacks. */
|
|
2035
|
+
async function partnerAccount(context, input) {
|
|
2036
|
+
const { partners, partner } = partnerOf(context);
|
|
2037
|
+
const tenantRef = await partners.tenantOf(partner, input.account);
|
|
2038
|
+
if (!tenantRef)
|
|
2039
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2040
|
+
// Its test environment (RFC 0003 G9): a tenant of its own, on the same plan, never billed.
|
|
2041
|
+
return { partners, partner, tenantRef: input.environment === 'test' ? `${tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}` : tenantRef };
|
|
2042
|
+
}
|
|
1221
2043
|
return {
|
|
1222
2044
|
async createResource(context, input) {
|
|
1223
2045
|
// A capped create counts under READ COMMITTED; withTenant opens it for the call.
|
|
@@ -1226,11 +2048,15 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1226
2048
|
const resource = await tenant.createResource({
|
|
1227
2049
|
tenantRef: tenantOf(context),
|
|
1228
2050
|
externalRef: input.external_ref,
|
|
1229
|
-
timezone: input.timezone,
|
|
2051
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2052
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
1230
2053
|
...(input.visibility !== undefined
|
|
1231
2054
|
? { visibility: input.visibility }
|
|
1232
2055
|
: {}),
|
|
1233
2056
|
...(max !== undefined ? { maxTenantResources: max } : {}),
|
|
2057
|
+
...(SLOTLOCK_RESOURCE_PROFILE_FIELDS.some((field) => input[field] !== undefined)
|
|
2058
|
+
? { profile: profileInput(input) }
|
|
2059
|
+
: {}),
|
|
1234
2060
|
});
|
|
1235
2061
|
return { resource: resourceOutput(resource) };
|
|
1236
2062
|
}
|
|
@@ -1239,6 +2065,151 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1239
2065
|
}
|
|
1240
2066
|
}, max !== undefined ? { isolation: 'read committed' } : undefined);
|
|
1241
2067
|
},
|
|
2068
|
+
async updateResource(context, input) {
|
|
2069
|
+
const id = input.resource_id;
|
|
2070
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
2071
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2072
|
+
return withTenant(store, context, async (tenant) => {
|
|
2073
|
+
let resource;
|
|
2074
|
+
try {
|
|
2075
|
+
resource = await tenant.updateResourceProfile({
|
|
2076
|
+
tenantRef: tenantOf(context),
|
|
2077
|
+
id,
|
|
2078
|
+
profile: profileInput(input),
|
|
2079
|
+
...(input.location !== undefined ? { location: input.location } : {}),
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
catch (error) {
|
|
2083
|
+
throw storeRefusal(error);
|
|
2084
|
+
}
|
|
2085
|
+
if (!resource)
|
|
2086
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2087
|
+
return { resource: resourceOutput(resource) };
|
|
2088
|
+
}, WRITE);
|
|
2089
|
+
},
|
|
2090
|
+
async listLocations(context) {
|
|
2091
|
+
return withTenant(store, context, async (tenant) => ({
|
|
2092
|
+
locations: (await tenant.listLocations({ tenantRef: tenantOf(context) })).map(locationOutput),
|
|
2093
|
+
}));
|
|
2094
|
+
},
|
|
2095
|
+
async createLocation(context, input) {
|
|
2096
|
+
return withTenant(store, context, async (tenant) => {
|
|
2097
|
+
try {
|
|
2098
|
+
const location = await tenant.putLocation({
|
|
2099
|
+
tenantRef: tenantOf(context),
|
|
2100
|
+
ref: input.ref,
|
|
2101
|
+
name: input.name,
|
|
2102
|
+
...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
|
|
2103
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2104
|
+
});
|
|
2105
|
+
return { location: locationOutput(location) };
|
|
2106
|
+
}
|
|
2107
|
+
catch (error) {
|
|
2108
|
+
throw storeRefusal(error);
|
|
2109
|
+
}
|
|
2110
|
+
}, WRITE);
|
|
2111
|
+
},
|
|
2112
|
+
async updateLocation(context, input) {
|
|
2113
|
+
return withTenant(store, context, async (tenant) => {
|
|
2114
|
+
let location;
|
|
2115
|
+
try {
|
|
2116
|
+
location = await tenant.updateLocation({
|
|
2117
|
+
tenantRef: tenantOf(context),
|
|
2118
|
+
ref: input.location_ref,
|
|
2119
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
2120
|
+
...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
|
|
2121
|
+
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
catch (error) {
|
|
2125
|
+
throw storeRefusal(error);
|
|
2126
|
+
}
|
|
2127
|
+
if (!location)
|
|
2128
|
+
throw new SlotlockAgentOperationError('location_not_found', 404);
|
|
2129
|
+
return { location: locationOutput(location) };
|
|
2130
|
+
}, WRITE);
|
|
2131
|
+
},
|
|
2132
|
+
async deleteLocation(context, input) {
|
|
2133
|
+
return withTenant(store, context, async (tenant) => {
|
|
2134
|
+
try {
|
|
2135
|
+
return {
|
|
2136
|
+
deleted: await tenant.deleteLocation({ tenantRef: tenantOf(context), ref: input.location_ref }),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
catch (error) {
|
|
2140
|
+
throw storeRefusal(error);
|
|
2141
|
+
}
|
|
2142
|
+
}, WRITE);
|
|
2143
|
+
},
|
|
2144
|
+
async listGroups(context) {
|
|
2145
|
+
return withTenant(store, context, async (tenant) => ({
|
|
2146
|
+
groups: (await tenant.listGroups({ tenantRef: tenantOf(context) })).map(groupOutput),
|
|
2147
|
+
}));
|
|
2148
|
+
},
|
|
2149
|
+
async createGroup(context, input) {
|
|
2150
|
+
return withTenant(store, context, async (tenant) => {
|
|
2151
|
+
try {
|
|
2152
|
+
const group = await tenant.putGroup({
|
|
2153
|
+
tenantRef: tenantOf(context),
|
|
2154
|
+
ref: input.ref,
|
|
2155
|
+
name: input.name,
|
|
2156
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
2157
|
+
});
|
|
2158
|
+
return { group: groupOutput(group) };
|
|
2159
|
+
}
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
throw storeRefusal(error);
|
|
2162
|
+
}
|
|
2163
|
+
}, WRITE);
|
|
2164
|
+
},
|
|
2165
|
+
async deleteGroup(context, input) {
|
|
2166
|
+
return withTenant(store, context, async (tenant) => {
|
|
2167
|
+
try {
|
|
2168
|
+
return { deleted: await tenant.deleteGroup({ tenantRef: tenantOf(context), ref: input.group_ref }) };
|
|
2169
|
+
}
|
|
2170
|
+
catch (error) {
|
|
2171
|
+
throw storeRefusal(error);
|
|
2172
|
+
}
|
|
2173
|
+
}, WRITE);
|
|
2174
|
+
},
|
|
2175
|
+
async addGroupMember(context, input) {
|
|
2176
|
+
const resourceId = input.resource_id;
|
|
2177
|
+
if (!STORE_ID_PATTERN.test(resourceId))
|
|
2178
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2179
|
+
return withTenant(store, context, async (tenant) => {
|
|
2180
|
+
try {
|
|
2181
|
+
return {
|
|
2182
|
+
added: await tenant.addGroupMember({
|
|
2183
|
+
tenantRef: tenantOf(context),
|
|
2184
|
+
group: input.group_ref,
|
|
2185
|
+
resourceId,
|
|
2186
|
+
}),
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
catch (error) {
|
|
2190
|
+
throw storeRefusal(error);
|
|
2191
|
+
}
|
|
2192
|
+
}, WRITE);
|
|
2193
|
+
},
|
|
2194
|
+
async removeGroupMember(context, input) {
|
|
2195
|
+
const resourceId = input.resource_id;
|
|
2196
|
+
if (!STORE_ID_PATTERN.test(resourceId))
|
|
2197
|
+
return { removed: false };
|
|
2198
|
+
return withTenant(store, context, async (tenant) => {
|
|
2199
|
+
try {
|
|
2200
|
+
return {
|
|
2201
|
+
removed: await tenant.removeGroupMember({
|
|
2202
|
+
tenantRef: tenantOf(context),
|
|
2203
|
+
group: input.group_ref,
|
|
2204
|
+
resourceId,
|
|
2205
|
+
}),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
catch (error) {
|
|
2209
|
+
throw storeRefusal(error);
|
|
2210
|
+
}
|
|
2211
|
+
}, WRITE);
|
|
2212
|
+
},
|
|
1242
2213
|
async getResource(context, input) {
|
|
1243
2214
|
return withTenant(store, context, async (tenant) => ({
|
|
1244
2215
|
resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
|
|
@@ -1399,6 +2370,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1399
2370
|
return { resource: resourceOutput(resource) };
|
|
1400
2371
|
}, WRITE);
|
|
1401
2372
|
},
|
|
2373
|
+
async setResourceParent(context, input) {
|
|
2374
|
+
const id = input.resource_id;
|
|
2375
|
+
const parentId = input.parent_id;
|
|
2376
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
2377
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2378
|
+
if (parentId !== null && !STORE_ID_PATTERN.test(parentId))
|
|
2379
|
+
throw new SlotlockAgentOperationError('parent_not_found', 404);
|
|
2380
|
+
return withTenant(store, context, async (tenant) => {
|
|
2381
|
+
let resource;
|
|
2382
|
+
try {
|
|
2383
|
+
resource = await tenant.setResourceParent({ tenantRef: tenantOf(context), id, parentId });
|
|
2384
|
+
}
|
|
2385
|
+
catch (error) {
|
|
2386
|
+
throw storeRefusal(error);
|
|
2387
|
+
}
|
|
2388
|
+
if (!resource)
|
|
2389
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
2390
|
+
return { resource: resourceOutput(resource) };
|
|
2391
|
+
}, WRITE);
|
|
2392
|
+
},
|
|
1402
2393
|
async getResourceHistory(context, input) {
|
|
1403
2394
|
return history(context, input);
|
|
1404
2395
|
},
|
|
@@ -1413,6 +2404,21 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1413
2404
|
pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
|
|
1414
2405
|
}));
|
|
1415
2406
|
},
|
|
2407
|
+
async setPoolOrder(context, input) {
|
|
2408
|
+
return withTenant(store, context, async (tenant) => {
|
|
2409
|
+
try {
|
|
2410
|
+
const order = await tenant.setPoolOrder({
|
|
2411
|
+
tenantRef: tenantOf(context),
|
|
2412
|
+
pool: input.pool,
|
|
2413
|
+
order: input.order,
|
|
2414
|
+
});
|
|
2415
|
+
return { pool: input.pool, order };
|
|
2416
|
+
}
|
|
2417
|
+
catch (error) {
|
|
2418
|
+
throw storeRefusal(error);
|
|
2419
|
+
}
|
|
2420
|
+
}, WRITE);
|
|
2421
|
+
},
|
|
1416
2422
|
async getBookingRuleDefaults(context) {
|
|
1417
2423
|
return withTenant(store, context, async (tenant) => {
|
|
1418
2424
|
const defaults = await tenant.getBookingRuleDefaults({ tenantRef: tenantOf(context) });
|
|
@@ -1536,7 +2542,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1536
2542
|
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
1537
2543
|
}));
|
|
1538
2544
|
if (released.released)
|
|
1539
|
-
await offerFreedTime(store,
|
|
2545
|
+
await offerFreedTime(store, context);
|
|
1540
2546
|
return released;
|
|
1541
2547
|
},
|
|
1542
2548
|
async getReservation(context, input) {
|
|
@@ -1586,7 +2592,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1586
2592
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
1587
2593
|
}, WRITE);
|
|
1588
2594
|
if (input.revision > 1 && !put.replayed) {
|
|
1589
|
-
await offerFreedTime(store,
|
|
2595
|
+
await offerFreedTime(store, context);
|
|
1590
2596
|
}
|
|
1591
2597
|
return put;
|
|
1592
2598
|
},
|
|
@@ -1602,7 +2608,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1602
2608
|
return { cancelled: result.cancelled, revision: result.revision };
|
|
1603
2609
|
}, WRITE);
|
|
1604
2610
|
if (cancelled.cancelled)
|
|
1605
|
-
await offerFreedTime(store,
|
|
2611
|
+
await offerFreedTime(store, context);
|
|
1606
2612
|
return cancelled;
|
|
1607
2613
|
},
|
|
1608
2614
|
async checkInReservation(context, input) {
|
|
@@ -1627,10 +2633,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1627
2633
|
end: new Date(input.end),
|
|
1628
2634
|
holderRef: input.holder_ref,
|
|
1629
2635
|
...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
|
|
2636
|
+
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
1630
2637
|
});
|
|
1631
2638
|
if (!result.ok)
|
|
1632
2639
|
throw applicationRefusal(result.code);
|
|
1633
|
-
return { entry: waitlistOutput(result.entry) };
|
|
2640
|
+
return { entry: waitlistOutput(result.entry), replayed: result.replayed };
|
|
1634
2641
|
});
|
|
1635
2642
|
},
|
|
1636
2643
|
async listWaitlist(context, input) {
|
|
@@ -1649,7 +2656,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1649
2656
|
left: await tenant.leaveWaitlist({ tenantRef: tenantOf(context), id: input.entry_id }),
|
|
1650
2657
|
}));
|
|
1651
2658
|
if (left.left)
|
|
1652
|
-
await offerFreedTime(store,
|
|
2659
|
+
await offerFreedTime(store, context);
|
|
1653
2660
|
return left;
|
|
1654
2661
|
},
|
|
1655
2662
|
async getCoverage(context, input) {
|
|
@@ -1776,7 +2783,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1776
2783
|
};
|
|
1777
2784
|
}, WRITE);
|
|
1778
2785
|
if (!cancelled.replayed)
|
|
1779
|
-
await offerFreedTime(store,
|
|
2786
|
+
await offerFreedTime(store, context);
|
|
1780
2787
|
return cancelled;
|
|
1781
2788
|
},
|
|
1782
2789
|
async getTenant(context) {
|
|
@@ -1800,7 +2807,13 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1800
2807
|
if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
|
|
1801
2808
|
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
1802
2809
|
}
|
|
1803
|
-
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) })
|
|
2810
|
+
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) }).catch((error) => {
|
|
2811
|
+
// A paid hosted plan is cancelled first, so it does not renew for an erased tenant.
|
|
2812
|
+
if (error?.code === 'billing_active') {
|
|
2813
|
+
throw new SlotlockAgentOperationError('billing_active', 409);
|
|
2814
|
+
}
|
|
2815
|
+
throw error;
|
|
2816
|
+
});
|
|
1804
2817
|
return { total: erased.total, rows: erased.rows };
|
|
1805
2818
|
}, WRITE);
|
|
1806
2819
|
},
|
|
@@ -1823,11 +2836,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1823
2836
|
tenantRef: tenantOf(context),
|
|
1824
2837
|
url: input.url,
|
|
1825
2838
|
...(input.event_types !== undefined ? { eventTypes: input.event_types } : {}),
|
|
2839
|
+
...(['resource_ids', 'pools', 'locations', 'groups'].some((field) => input[field] !== undefined)
|
|
2840
|
+
? {
|
|
2841
|
+
filter: {
|
|
2842
|
+
...(input.resource_ids !== undefined ? { resourceIds: input.resource_ids } : {}),
|
|
2843
|
+
...(input.pools !== undefined ? { pools: input.pools } : {}),
|
|
2844
|
+
...(input.locations !== undefined ? { locations: input.locations } : {}),
|
|
2845
|
+
...(input.groups !== undefined ? { groups: input.groups } : {}),
|
|
2846
|
+
},
|
|
2847
|
+
}
|
|
2848
|
+
: {}),
|
|
2849
|
+
...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
|
|
1826
2850
|
});
|
|
1827
2851
|
if (!created.ok) {
|
|
1828
|
-
throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' ? 409 : 400);
|
|
2852
|
+
throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' || created.code === 'idempotency_conflict' ? 409 : 400);
|
|
1829
2853
|
}
|
|
1830
|
-
return {
|
|
2854
|
+
return {
|
|
2855
|
+
webhook: webhookOutput(created.endpoint),
|
|
2856
|
+
secret: created.secret,
|
|
2857
|
+
replayed: created.replayed === true,
|
|
2858
|
+
};
|
|
1831
2859
|
}, WRITE);
|
|
1832
2860
|
},
|
|
1833
2861
|
async listWebhooks(context) {
|
|
@@ -1838,6 +2866,196 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1838
2866
|
async deleteWebhook(context, input) {
|
|
1839
2867
|
return withTenant(store, context, async (tenant) => tenant.deleteWebhookEndpoint({ tenantRef: tenantOf(context), id: input.webhook_id }), WRITE);
|
|
1840
2868
|
},
|
|
2869
|
+
// The partner API (RFC 0003 P3): the partner the key names, its own accounts only.
|
|
2870
|
+
async partnerPutAccount(context, input) {
|
|
2871
|
+
const { partners, partner } = partnerOf(context);
|
|
2872
|
+
const put = await partners.putAccount(partner, input.account, input.plan);
|
|
2873
|
+
if (!put)
|
|
2874
|
+
throw new SlotlockAgentOperationError('forbidden', 403);
|
|
2875
|
+
return { account: partnerAccountOutput(put.account), created: put.created };
|
|
2876
|
+
},
|
|
2877
|
+
async partnerListAccounts(context) {
|
|
2878
|
+
const { partners, partner } = partnerOf(context);
|
|
2879
|
+
return { accounts: (await partners.listAccounts(partner)).map(partnerAccountOutput) };
|
|
2880
|
+
},
|
|
2881
|
+
async partnerGetAccount(context, input) {
|
|
2882
|
+
const { partners, partner } = partnerOf(context);
|
|
2883
|
+
const found = await partners.getAccount(partner, input.account);
|
|
2884
|
+
if (!found)
|
|
2885
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2886
|
+
return { account: partnerAccountOutput(found) };
|
|
2887
|
+
},
|
|
2888
|
+
async partnerListKeys(context, input) {
|
|
2889
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2890
|
+
return { keys: (await partners.keys.list({ tenantRef })).map(slotlockPartnerKeyOutput) };
|
|
2891
|
+
},
|
|
2892
|
+
async partnerCreateKey(context, input) {
|
|
2893
|
+
const { partners, partner, tenantRef } = await partnerAccount(context, input);
|
|
2894
|
+
const contract = await partners.contract(partner);
|
|
2895
|
+
if (!contract)
|
|
2896
|
+
throw new SlotlockAgentOperationError('forbidden', 403);
|
|
2897
|
+
const scopes = input.scopes ?? [...contract.keyScopes];
|
|
2898
|
+
if (scopes.some((scope) => !contract.keyScopes.includes(scope))) {
|
|
2899
|
+
throw new SlotlockAgentOperationError('scope_not_in_contract', 403);
|
|
2900
|
+
}
|
|
2901
|
+
const days = input.expires_in_days;
|
|
2902
|
+
try {
|
|
2903
|
+
const created = await partners.keys.create({
|
|
2904
|
+
tenantRef,
|
|
2905
|
+
name: input.name,
|
|
2906
|
+
scopes,
|
|
2907
|
+
createdBy: `partner:${partner}`,
|
|
2908
|
+
actor: context.principal.subject,
|
|
2909
|
+
origin: 'application',
|
|
2910
|
+
...(days !== undefined ? { expiresAt: new Date(Date.now() + days * 86_400_000) } : {}),
|
|
2911
|
+
...(input.limits !== undefined ? { limits: keyLimitsFromInput(input.limits) } : {}),
|
|
2912
|
+
});
|
|
2913
|
+
return { key: created.key, api_key: slotlockPartnerKeyOutput(created.apiKey) };
|
|
2914
|
+
}
|
|
2915
|
+
catch (error) {
|
|
2916
|
+
const code = error.code;
|
|
2917
|
+
if (code === 'api_key_limit_reached')
|
|
2918
|
+
throw new SlotlockAgentOperationError(code, 409);
|
|
2919
|
+
if (code === 'tenant_suspended')
|
|
2920
|
+
throw new SlotlockAgentOperationError(code, 403);
|
|
2921
|
+
throw error;
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
async partnerRotateKey(context, input) {
|
|
2925
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2926
|
+
const rotated = await partners.keys
|
|
2927
|
+
.rotate({ tenantRef, id: input.key_id, actor: context.principal.subject, origin: 'application' })
|
|
2928
|
+
.catch((error) => {
|
|
2929
|
+
if (error.code === 'tenant_suspended') {
|
|
2930
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
2931
|
+
}
|
|
2932
|
+
throw error;
|
|
2933
|
+
});
|
|
2934
|
+
if (!rotated)
|
|
2935
|
+
throw new SlotlockAgentOperationError('key_not_found', 404);
|
|
2936
|
+
return { key: rotated.key, api_key: slotlockPartnerKeyOutput(rotated.apiKey) };
|
|
2937
|
+
},
|
|
2938
|
+
async partnerRevokeKey(context, input) {
|
|
2939
|
+
const { partners, tenantRef } = await partnerAccount(context, input);
|
|
2940
|
+
const revoked = await partners.keys.revoke({
|
|
2941
|
+
tenantRef,
|
|
2942
|
+
id: input.key_id,
|
|
2943
|
+
actor: context.principal.subject,
|
|
2944
|
+
origin: 'application',
|
|
2945
|
+
});
|
|
2946
|
+
if (!revoked)
|
|
2947
|
+
throw new SlotlockAgentOperationError('key_not_found', 404);
|
|
2948
|
+
return { api_key: slotlockPartnerKeyOutput(revoked) };
|
|
2949
|
+
},
|
|
2950
|
+
async partnerSuspendAccount(context, input) {
|
|
2951
|
+
const { partners, partner } = partnerOf(context);
|
|
2952
|
+
const changed = await partners.setSuspended(partner, input.account, true, input.reason);
|
|
2953
|
+
if (changed === null)
|
|
2954
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2955
|
+
return { suspended: changed };
|
|
2956
|
+
},
|
|
2957
|
+
async partnerUnsuspendAccount(context, input) {
|
|
2958
|
+
const { partners, partner } = partnerOf(context);
|
|
2959
|
+
const changed = await partners.setSuspended(partner, input.account, false);
|
|
2960
|
+
if (changed === null)
|
|
2961
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2962
|
+
return { unsuspended: changed };
|
|
2963
|
+
},
|
|
2964
|
+
async partnerEraseAccount(context, input) {
|
|
2965
|
+
const { partners, partner } = partnerOf(context);
|
|
2966
|
+
if (input.confirm !== input.account)
|
|
2967
|
+
throw new SlotlockAgentOperationError('confirmation_mismatch', 400);
|
|
2968
|
+
const erased = await partners.eraseAccount(partner, input.account);
|
|
2969
|
+
if (!erased.erased)
|
|
2970
|
+
throw new SlotlockAgentOperationError('account_not_found', 404);
|
|
2971
|
+
return { erased: true, rows: erased.total };
|
|
2972
|
+
},
|
|
2973
|
+
async partnerGetUsage(context, input) {
|
|
2974
|
+
const { partners, partner } = partnerOf(context);
|
|
2975
|
+
const now = Date.now();
|
|
2976
|
+
const range = slotlockUsageMonthToDate(now);
|
|
2977
|
+
const from = input.from ?? range.from;
|
|
2978
|
+
const to = input.to ?? range.to;
|
|
2979
|
+
const span = slotlockUsageSpanDays(from, to);
|
|
2980
|
+
if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
|
|
2981
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
2982
|
+
}
|
|
2983
|
+
const rows = await partners.usage(partner, from, to, input.account);
|
|
2984
|
+
return { from, to, usage: rows };
|
|
2985
|
+
},
|
|
2986
|
+
async partnerReconcileUsage(context, input) {
|
|
2987
|
+
const { partners, partner } = partnerOf(context);
|
|
2988
|
+
const day = input.day;
|
|
2989
|
+
// An ended day: today's counts are still arriving.
|
|
2990
|
+
if (slotlockUsageSpanDays(day, slotlockUsageDay(Date.now())) < 1) {
|
|
2991
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
2992
|
+
}
|
|
2993
|
+
const theirs = new Map();
|
|
2994
|
+
for (const row of input.accounts) {
|
|
2995
|
+
theirs.set(row.account, { operations: row.operations, ...(row.bookings !== undefined ? { bookings: row.bookings } : {}) });
|
|
2996
|
+
}
|
|
2997
|
+
const ours = new Map();
|
|
2998
|
+
for (const row of await partners.usage(partner, day, day)) {
|
|
2999
|
+
if (row.metric !== 'operations' && row.metric !== 'bookings')
|
|
3000
|
+
continue;
|
|
3001
|
+
const counted = ours.get(row.account) ?? { operations: 0, bookings: 0 };
|
|
3002
|
+
counted[row.metric] += row.count;
|
|
3003
|
+
ours.set(row.account, counted);
|
|
3004
|
+
}
|
|
3005
|
+
const accounts = [...new Set([...ours.keys(), ...theirs.keys()])].sort();
|
|
3006
|
+
const lines = [];
|
|
3007
|
+
const mismatched = new Set();
|
|
3008
|
+
let worst = 0;
|
|
3009
|
+
for (const account of accounts) {
|
|
3010
|
+
for (const metric of ['operations', 'bookings']) {
|
|
3011
|
+
const mine = ours.get(account)?.[metric] ?? 0;
|
|
3012
|
+
const told = theirs.get(account)?.[metric];
|
|
3013
|
+
if (told === undefined && (metric === 'bookings' || !theirs.has(account)) && mine === 0)
|
|
3014
|
+
continue;
|
|
3015
|
+
// Not sent by them, where we counted some: a difference of all of it.
|
|
3016
|
+
const theirCount = told ?? null;
|
|
3017
|
+
const difference = theirCount === null
|
|
3018
|
+
? mine === 0 ? 0 : 100
|
|
3019
|
+
: Math.max(mine, theirCount) === 0 ? 0 : (Math.abs(mine - theirCount) * 100) / Math.max(mine, theirCount);
|
|
3020
|
+
if (theirCount === null && metric === 'bookings') {
|
|
3021
|
+
lines.push({ account, metric, ours: mine, theirs: null, difference_percent: null });
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
const rounded = Math.round(difference * 100) / 100;
|
|
3025
|
+
if (difference > SLOTLOCK_RECONCILE_TOLERANCE_PERCENT)
|
|
3026
|
+
mismatched.add(account);
|
|
3027
|
+
worst = Math.max(worst, rounded);
|
|
3028
|
+
lines.push({ account, metric, ours: mine, theirs: theirCount, difference_percent: rounded });
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
await partners.recordReconciliation(partner, day, accounts.length, mismatched.size, worst);
|
|
3032
|
+
return {
|
|
3033
|
+
day,
|
|
3034
|
+
within_tolerance: mismatched.size === 0,
|
|
3035
|
+
tolerance_percent: SLOTLOCK_RECONCILE_TOLERANCE_PERCENT,
|
|
3036
|
+
accounts: lines,
|
|
3037
|
+
};
|
|
3038
|
+
},
|
|
3039
|
+
async getUsage(context, input) {
|
|
3040
|
+
const now = Date.now();
|
|
3041
|
+
const range = slotlockUsageMonthToDate(now);
|
|
3042
|
+
const from = input.from ?? range.from;
|
|
3043
|
+
const to = input.to ?? range.to;
|
|
3044
|
+
const span = slotlockUsageSpanDays(from, to);
|
|
3045
|
+
if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
|
|
3046
|
+
throw new SlotlockAgentOperationError('invalid_usage_range', 400);
|
|
3047
|
+
}
|
|
3048
|
+
const rows = await withTenant(store, context, (tenant) => tenant.readUsage({ tenantRef: tenantOf(context), from, to }));
|
|
3049
|
+
const summary = summarizeSlotlockUsage(rows);
|
|
3050
|
+
return {
|
|
3051
|
+
from,
|
|
3052
|
+
to,
|
|
3053
|
+
totals: summary.totals,
|
|
3054
|
+
days: summary.days,
|
|
3055
|
+
keys: summary.keys.map(({ keyId, ...totals }) => ({ key_id: keyId, ...totals })),
|
|
3056
|
+
surfaces: summary.surfaces,
|
|
3057
|
+
};
|
|
3058
|
+
},
|
|
1841
3059
|
async enableWebhook(context, input) {
|
|
1842
3060
|
return withTenant(store, context, async (tenant) => {
|
|
1843
3061
|
const enabled = await tenant.enableWebhookEndpoint({
|
|
@@ -1851,10 +3069,152 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1851
3069
|
return { webhook: webhookOutput(enabled) };
|
|
1852
3070
|
}, WRITE);
|
|
1853
3071
|
},
|
|
3072
|
+
async listApprovalPolicies(context) {
|
|
3073
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3074
|
+
policies: (await tenant.listApprovalPolicies({ tenantRef: tenantOf(context) })).map(approvalPolicyOutput),
|
|
3075
|
+
}));
|
|
3076
|
+
},
|
|
3077
|
+
async putApprovalPolicy(context, input) {
|
|
3078
|
+
return withTenant(store, context, async (tenant) => {
|
|
3079
|
+
let policy;
|
|
3080
|
+
try {
|
|
3081
|
+
policy = await tenant.putApprovalPolicy({
|
|
3082
|
+
tenantRef: tenantOf(context),
|
|
3083
|
+
ref: input.policy_ref,
|
|
3084
|
+
name: input.name,
|
|
3085
|
+
outcome: input.outcome,
|
|
3086
|
+
...(input.applies_to !== undefined ? { appliesTo: approvalAppliesTo(input.applies_to) } : {}),
|
|
3087
|
+
...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
|
|
3088
|
+
...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
|
|
3089
|
+
...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
catch (error) {
|
|
3093
|
+
if (error instanceof SlotlockApprovalPolicyError) {
|
|
3094
|
+
throw new SlotlockAgentOperationError(error.code, 400, { reason: error.message.slice(0, 200) });
|
|
3095
|
+
}
|
|
3096
|
+
throw error;
|
|
3097
|
+
}
|
|
3098
|
+
if (!policy)
|
|
3099
|
+
throw new SlotlockAgentOperationError('approval_policy_limit_reached', 409);
|
|
3100
|
+
return { policy: approvalPolicyOutput(policy) };
|
|
3101
|
+
}, WRITE);
|
|
3102
|
+
},
|
|
3103
|
+
async deleteApprovalPolicy(context, input) {
|
|
3104
|
+
return withTenant(store, context, (tenant) => tenant.deleteApprovalPolicy({ tenantRef: tenantOf(context), ref: input.policy_ref }));
|
|
3105
|
+
},
|
|
3106
|
+
async listApprovals(context, input) {
|
|
3107
|
+
return withTenant(store, context, async (tenant) => ({
|
|
3108
|
+
approvals: (await tenant.listApprovals({
|
|
3109
|
+
tenantRef: tenantOf(context),
|
|
3110
|
+
limit: input.limit,
|
|
3111
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
3112
|
+
})).map(approvalOutput),
|
|
3113
|
+
}));
|
|
3114
|
+
},
|
|
3115
|
+
async getApproval(context, input) {
|
|
3116
|
+
return withTenant(store, context, async (tenant) => {
|
|
3117
|
+
const approval = await tenant.getApproval({ tenantRef: tenantOf(context), id: input.approval_id });
|
|
3118
|
+
if (!approval)
|
|
3119
|
+
throw new SlotlockAgentOperationError('approval_not_found', 404);
|
|
3120
|
+
return { approval: approvalOutput(approval) };
|
|
3121
|
+
});
|
|
3122
|
+
},
|
|
3123
|
+
async decideApproval(context, input) {
|
|
3124
|
+
const tenantRef = tenantOf(context);
|
|
3125
|
+
const id = input.approval_id;
|
|
3126
|
+
const decidedBy = actorOf(context) ?? 'unknown';
|
|
3127
|
+
if (input.decision === 'decline') {
|
|
3128
|
+
const declined = await withTenant(store, context, (tenant) => tenant.declineApproval({
|
|
3129
|
+
tenantRef,
|
|
3130
|
+
id,
|
|
3131
|
+
decidedBy,
|
|
3132
|
+
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
|
3133
|
+
}), WRITE);
|
|
3134
|
+
if (!declined.ok)
|
|
3135
|
+
throw approvalRefusal(declined.code);
|
|
3136
|
+
await offerFreedTime(store, context);
|
|
3137
|
+
return { approval: approvalOutput(declined.approval), booking: null };
|
|
3138
|
+
}
|
|
3139
|
+
if (!options.agentBackend)
|
|
3140
|
+
throw new SlotlockAgentOperationError('approvals_unavailable', 409);
|
|
3141
|
+
const approval = await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
3142
|
+
if (!approval)
|
|
3143
|
+
throw approvalRefusal('approval_not_found');
|
|
3144
|
+
if (approval.status !== 'pending')
|
|
3145
|
+
throw approvalRefusal('approval_not_pending');
|
|
3146
|
+
// A dry run's grant is a dry run of the booking: rolled back with it.
|
|
3147
|
+
const booking = await runSlotlockApprovalGrant(options.agentBackend, approval, {
|
|
3148
|
+
tenantRef,
|
|
3149
|
+
decidedBy,
|
|
3150
|
+
signal: context.signal,
|
|
3151
|
+
...(context.origin !== undefined ? { origin: context.origin } : {}),
|
|
3152
|
+
...(context.dryRun ? { dryRun: true } : {}),
|
|
3153
|
+
});
|
|
3154
|
+
const granted = context.dryRun
|
|
3155
|
+
? { ...approval, status: 'granted', decidedBy, decidedAt: new Date() }
|
|
3156
|
+
: await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
|
|
3157
|
+
if (!granted)
|
|
3158
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
3159
|
+
return { approval: approvalOutput(granted), booking };
|
|
3160
|
+
},
|
|
3161
|
+
async sandboxSeed(context, input) {
|
|
3162
|
+
try {
|
|
3163
|
+
return { ...(await seedSlotlockSandbox(store, tenantOf(context), input.scenario)) };
|
|
3164
|
+
}
|
|
3165
|
+
catch (error) {
|
|
3166
|
+
throw sandboxRefusal(error);
|
|
3167
|
+
}
|
|
3168
|
+
},
|
|
3169
|
+
async sandboxReset(context) {
|
|
3170
|
+
try {
|
|
3171
|
+
return { removed: await resetSlotlockSandbox(store, tenantOf(context)) };
|
|
3172
|
+
}
|
|
3173
|
+
catch (error) {
|
|
3174
|
+
throw sandboxRefusal(error);
|
|
3175
|
+
}
|
|
3176
|
+
},
|
|
3177
|
+
async sandboxFastForward(context, input) {
|
|
3178
|
+
try {
|
|
3179
|
+
const forward = await fastForwardSlotlockSandbox(store, tenantOf(context), input.minutes);
|
|
3180
|
+
const { rollPending: _, ...maintenance } = forward.maintenance;
|
|
3181
|
+
return { minutes: forward.minutes, holds: forward.holds, approvals: forward.approvals, maintenance };
|
|
3182
|
+
}
|
|
3183
|
+
catch (error) {
|
|
3184
|
+
throw sandboxRefusal(error);
|
|
3185
|
+
}
|
|
3186
|
+
},
|
|
3187
|
+
async sandboxRace(context, input) {
|
|
3188
|
+
if (!options.agentBackend)
|
|
3189
|
+
throw new SlotlockAgentOperationError('sandbox_unavailable', 409);
|
|
3190
|
+
try {
|
|
3191
|
+
const raced = await raceSlotlockSandbox({
|
|
3192
|
+
backend: options.agentBackend,
|
|
3193
|
+
tenantRef: tenantOf(context),
|
|
3194
|
+
resourceId: input.resource_id,
|
|
3195
|
+
start: new Date(input.starts_at),
|
|
3196
|
+
end: new Date(input.ends_at),
|
|
3197
|
+
...(input.agents !== undefined ? { agents: input.agents } : {}),
|
|
3198
|
+
});
|
|
3199
|
+
return {
|
|
3200
|
+
agents: raced.agents,
|
|
3201
|
+
booked: raced.booked,
|
|
3202
|
+
refused: raced.refused,
|
|
3203
|
+
with_alternatives: raced.withAlternatives,
|
|
3204
|
+
server_errors: raced.serverErrors,
|
|
3205
|
+
p50_ms: raced.p50Ms,
|
|
3206
|
+
p99_ms: raced.p99Ms,
|
|
3207
|
+
max_ms: raced.maxMs,
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
catch (error) {
|
|
3211
|
+
throw sandboxRefusal(error);
|
|
3212
|
+
}
|
|
3213
|
+
},
|
|
1854
3214
|
async runMaintenance(context) {
|
|
1855
3215
|
assertActive(context);
|
|
1856
3216
|
try {
|
|
1857
|
-
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
3217
|
+
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1858
3218
|
batches,
|
|
1859
3219
|
signal: context.signal,
|
|
1860
3220
|
});
|
|
@@ -1864,6 +3224,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1864
3224
|
stale_sources: staleSources,
|
|
1865
3225
|
no_shows: noShows,
|
|
1866
3226
|
waitlist_offers: waitlistOffers,
|
|
3227
|
+
approvals_expired: approvalsExpired,
|
|
1867
3228
|
};
|
|
1868
3229
|
}
|
|
1869
3230
|
catch (error) {
|