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