slotlock 0.8.0 → 0.10.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 +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/maintenance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ignoreRejection } from './hooks.js';
|
|
2
|
-
import { SLOTLOCK_MAX_WAITLIST_PER_TENANT, calendarEventRollingHorizon, } from './store.js';
|
|
2
|
+
import { SLOTLOCK_MAX_WAITLIST_PER_TENANT, buildSlotlockTenantExport, calendarEventRollingHorizon, } from './store.js';
|
|
3
|
+
import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
3
4
|
/**
|
|
4
5
|
* One maintenance pass for a tenant: roll its recurring events to the horizon, then purge expired
|
|
5
6
|
* holds and prune bookkeeping past retention, each in at most `batches` batches (1-100, default
|
|
@@ -72,12 +73,25 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
72
73
|
options.signal?.throwIfAborted();
|
|
73
74
|
// Conversations expire 2 hours after their last message (dashboard.ts).
|
|
74
75
|
pruned += await withTenant((tenant) => tenant.pruneAssistantConversations({ tenantRef }));
|
|
76
|
+
// Audit events past retention (SLOTLOCK_AUDIT_RETENTION_DAYS, 180).
|
|
77
|
+
pruned += await withTenant((tenant) => tenant.pruneAuditEvents({ tenantRef }));
|
|
78
|
+
// Exports past their time go; one a stopped server left pending is built now (RFC 0004 D3).
|
|
79
|
+
const exports = await withTenant((tenant) => tenant.pruneTenantExports({ tenantRef }));
|
|
80
|
+
pruned += exports.deleted;
|
|
81
|
+
for (const exportId of exports.pending) {
|
|
82
|
+
options.signal?.throwIfAborted();
|
|
83
|
+
await buildSlotlockTenantExport(store, tenantRef, exportId);
|
|
84
|
+
}
|
|
75
85
|
const zones = await withTenant((tenant) => tenant.followZoneRules({ tenantRef, move: options.followZoneRules === true }));
|
|
76
86
|
const staleSources = await withTenant((tenant) => tenant.reportStaleCoverage({ tenantRef }));
|
|
77
87
|
// Approvals past their wait, and no-shows, first: the time they free is offered to the waitlist
|
|
78
88
|
// in the same pass.
|
|
79
89
|
const approvals = await withTenant((tenant) => tenant.expireApprovals({ tenantRef, limit: 100 }));
|
|
80
90
|
const noShows = await withTenant((tenant) => tenant.releaseNoShows({ tenantRef }));
|
|
91
|
+
// Bookings that ended since the last pass (RFC 0004 B4): no row changes, the feed says so.
|
|
92
|
+
const completed = await withTenant((tenant) => tenant.completeEndedBookings({ tenantRef, limit: 500 }));
|
|
93
|
+
const reminders = await withTenant((tenant) => tenant.dueReminders({ tenantRef, limit: 500 }));
|
|
94
|
+
const holidays = options.holidayFetch ? await importDueHolidayCalendars(withTenant, tenantRef, options) : 0;
|
|
81
95
|
// The whole waitlist in join order, a page per transaction, so a later free entry is offered
|
|
82
96
|
// however many wait before it.
|
|
83
97
|
let waitlistOffers = 0;
|
|
@@ -103,10 +117,58 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
103
117
|
noShows,
|
|
104
118
|
waitlistOffers,
|
|
105
119
|
approvalsExpired: approvals.expired,
|
|
106
|
-
|
|
120
|
+
bookingsCompleted: completed,
|
|
121
|
+
remindersDue: reminders,
|
|
122
|
+
holidayCalendarsImported: holidays,
|
|
123
|
+
capped: rollCapped ||
|
|
124
|
+
eventsLeft ||
|
|
125
|
+
bookingsLeft ||
|
|
126
|
+
changesLeft ||
|
|
127
|
+
approvals.expired === 100 ||
|
|
128
|
+
completed === 500 ||
|
|
129
|
+
reminders === 500,
|
|
107
130
|
rollPending: rollCapped && after !== null && (after.rollRetryAt ?? null) === null,
|
|
108
131
|
};
|
|
109
132
|
}
|
|
133
|
+
/** Holiday calendar subscriptions imported again in one pass, at most: each is a fetch. */
|
|
134
|
+
const HOLIDAY_IMPORTS_PER_PASS = 5;
|
|
135
|
+
/**
|
|
136
|
+
* Import again the tenant's holiday calendar subscriptions that are due: each fetched outside any
|
|
137
|
+
* transaction, then imported in one; one that cannot be read or imported keeps its closures and
|
|
138
|
+
* notes why, to be tried again a day later.
|
|
139
|
+
*/
|
|
140
|
+
async function importDueHolidayCalendars(withTenant, tenantRef, options) {
|
|
141
|
+
const due = await withTenant((tenant) => tenant.dueHolidayCalendars({ tenantRef, limit: HOLIDAY_IMPORTS_PER_PASS }));
|
|
142
|
+
let imported = 0;
|
|
143
|
+
for (const calendar of due) {
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
145
|
+
if (!calendar.url)
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
const read = await readSlotlockHolidayCalendar({ url: calendar.url, subscribe: true, name: calendar.name }, options.holidayFetch);
|
|
149
|
+
await withTenant((tenant) => tenant.importHolidayCalendar({
|
|
150
|
+
tenantRef,
|
|
151
|
+
...(calendar.location !== undefined ? { location: calendar.location } : {}),
|
|
152
|
+
...(calendar.resourceId !== undefined ? { resourceId: calendar.resourceId } : {}),
|
|
153
|
+
name: calendar.name,
|
|
154
|
+
...(read.url !== undefined ? { url: read.url } : {}),
|
|
155
|
+
holidays: read.holidays,
|
|
156
|
+
span: read.span,
|
|
157
|
+
}));
|
|
158
|
+
imported++;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (options.signal?.aborted)
|
|
162
|
+
throw error;
|
|
163
|
+
await withTenant((tenant) => tenant.noteHolidayCalendarFailure({
|
|
164
|
+
tenantRef,
|
|
165
|
+
id: calendar.id,
|
|
166
|
+
error: error instanceof Error ? error.message : 'The import failed',
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return imported;
|
|
171
|
+
}
|
|
110
172
|
/** Pages of 50 that cover a tenant's whole waitlist (SLOTLOCK_MAX_WAITLIST_PER_TENANT). */
|
|
111
173
|
const WAITLIST_PAGES = Math.ceil(SLOTLOCK_MAX_WAITLIST_PER_TENANT / 50);
|
|
112
174
|
/** How often the scheduler sweeps every tenant's expired assistant conversations, at most. */
|
|
@@ -183,6 +245,7 @@ export function startSlotlockMaintenance(options) {
|
|
|
183
245
|
const result = await withSlotlockMaintenanceLock(options.sql, tenantRef, () => maintainSlotlockTenant(options.store, tenantRef, {
|
|
184
246
|
...(options.batches !== undefined ? { batches: options.batches } : {}),
|
|
185
247
|
...(options.usage ? { usage: true } : {}),
|
|
248
|
+
...(options.holidayFetch ? { holidayFetch: options.holidayFetch } : {}),
|
|
186
249
|
signal: controller.signal,
|
|
187
250
|
}));
|
|
188
251
|
recordPass(tenantRef, Date.now());
|
|
@@ -239,6 +302,11 @@ export function startSlotlockMaintenance(options) {
|
|
|
239
302
|
return;
|
|
240
303
|
lastSweep = Date.now();
|
|
241
304
|
options.store.sweepAssistantConversations().catch(report);
|
|
305
|
+
// Team invitations finished a month ago, with the addresses they were sent to, and sessions
|
|
306
|
+
// ended a day ago.
|
|
307
|
+
options.store.sweepDashboardRecords().catch(report);
|
|
308
|
+
// Verified domains whose daily check (or a pending claim's hourly one) is due.
|
|
309
|
+
options.domainChecks?.().catch(report);
|
|
242
310
|
};
|
|
243
311
|
// Every tenant with data, once an interval: those no key has called since the server started too.
|
|
244
312
|
let lastDiscovery = Number.NEGATIVE_INFINITY;
|
package/metrics.js
CHANGED
|
@@ -19,6 +19,9 @@ const MAINTENANCE_COUNTS = [
|
|
|
19
19
|
'noShows',
|
|
20
20
|
'waitlistOffers',
|
|
21
21
|
'approvalsExpired',
|
|
22
|
+
'bookingsCompleted',
|
|
23
|
+
'remindersDue',
|
|
24
|
+
'holidayCalendarsImported',
|
|
22
25
|
];
|
|
23
26
|
/** A label value: an operation, protocol or outcome code. Anything else is counted as `other`. */
|
|
24
27
|
const LABEL = /^[a-z0-9_.]{1,64}$/;
|
package/oidc.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** The algorithms Slotlock verifies: RSA PKCS#1 v1.5, RSA-PSS and ECDSA P-256, all with SHA-256. */
|
|
2
|
+
export declare const SLOTLOCK_OIDC_ALGORITHMS: readonly ["RS256", "PS256", "ES256"];
|
|
3
|
+
export type SlotlockOidcAlgorithm = (typeof SLOTLOCK_OIDC_ALGORITHMS)[number];
|
|
4
|
+
/** Why an ID token was refused (never shown with the token). */
|
|
5
|
+
export type SlotlockIdTokenRefusal = 'malformed' | 'algorithm' | 'key' | 'signature' | 'issuer' | 'audience' | 'expired' | 'not_yet_valid' | 'issued_at' | 'nonce' | 'subject';
|
|
6
|
+
/** RFC 4648 base64url, without padding, to bytes; null when it is not that. */
|
|
7
|
+
export declare function slotlockBase64UrlBytes(text: string): Uint8Array<ArrayBuffer> | null;
|
|
8
|
+
/** Bytes as RFC 4648 base64url, without padding. */
|
|
9
|
+
export declare function slotlockBase64Url(bytes: Uint8Array): string;
|
|
10
|
+
/** A JWK as an issuer publishes it. */
|
|
11
|
+
export interface SlotlockJwk {
|
|
12
|
+
kty?: unknown;
|
|
13
|
+
kid?: unknown;
|
|
14
|
+
use?: unknown;
|
|
15
|
+
alg?: unknown;
|
|
16
|
+
n?: unknown;
|
|
17
|
+
e?: unknown;
|
|
18
|
+
crv?: unknown;
|
|
19
|
+
x?: unknown;
|
|
20
|
+
y?: unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A published key as a verification key for `alg`, or null when it does not fit it: RSA (at least
|
|
24
|
+
* 2048 bits) for RS256 and PS256, EC P-256 for ES256; `use`, when given, `sig`; `alg`, when given,
|
|
25
|
+
* the token's.
|
|
26
|
+
*/
|
|
27
|
+
export declare function importSlotlockJwk(jwk: SlotlockJwk, alg: SlotlockOidcAlgorithm): Promise<CryptoKey | null>;
|
|
28
|
+
/** Finds the key a token names: by issuer's JWKS and key id (see createSlotlockJwksCache). */
|
|
29
|
+
export type SlotlockKeyLookup = (kid: string | null, alg: SlotlockOidcAlgorithm) => Promise<CryptoKey | null>;
|
|
30
|
+
/** What an ID token is checked against. */
|
|
31
|
+
export interface SlotlockIdTokenExpectation {
|
|
32
|
+
/** The exact issuer, or the exact spellings one issuer documents (Google has two). */
|
|
33
|
+
issuers: readonly string[];
|
|
34
|
+
clientId: string;
|
|
35
|
+
/** The algorithms this issuer may sign with: a subset of SLOTLOCK_OIDC_ALGORITHMS. */
|
|
36
|
+
algorithms: readonly SlotlockOidcAlgorithm[];
|
|
37
|
+
keys: SlotlockKeyLookup;
|
|
38
|
+
nonce: string;
|
|
39
|
+
/** Milliseconds since the epoch. */
|
|
40
|
+
now: number;
|
|
41
|
+
}
|
|
42
|
+
/** An ID token checked: its claims, all of them, for the caller to read what it needs. */
|
|
43
|
+
export type SlotlockIdTokenResult = {
|
|
44
|
+
claims: Record<string, unknown>;
|
|
45
|
+
} | {
|
|
46
|
+
refusal: SlotlockIdTokenRefusal;
|
|
47
|
+
};
|
|
48
|
+
/** Check an ID token: its signature first, then its claims, in OpenID Connect Core's order. */
|
|
49
|
+
export declare function verifySlotlockIdToken(token: string, expected: SlotlockIdTokenExpectation): Promise<SlotlockIdTokenResult>;
|
|
50
|
+
/** Why a URL an Owner gave, or a document fetched from one, was refused. */
|
|
51
|
+
export declare class SlotlockOidcFetchError extends Error {
|
|
52
|
+
readonly code: 'url' | 'redirect' | 'status' | 'type' | 'size' | 'json' | 'network';
|
|
53
|
+
constructor(code: 'url' | 'redirect' | 'status' | 'type' | 'size' | 'json' | 'network', message: string);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether a URL is one Slotlock fetches for a team: https on the default port, a host name (never
|
|
57
|
+
* an IP literal), never localhost, a single label, or a private-use suffix; no credentials.
|
|
58
|
+
*/
|
|
59
|
+
export declare function slotlockOidcUrlAllowed(value: string): URL | null;
|
|
60
|
+
/**
|
|
61
|
+
* GET a JSON document from a URL an Owner influenced: refused unless slotlockOidcUrlAllowed; no
|
|
62
|
+
* redirect followed (any 3xx refuses); 5 seconds; 256 KB; a JSON content type. On Node, pass a
|
|
63
|
+
* fetch that refuses private addresses (webhooks.ts createSlotlockGuardedFetch).
|
|
64
|
+
*/
|
|
65
|
+
export declare function slotlockFetchOidcJson(value: string, options?: {
|
|
66
|
+
fetch?: typeof fetch;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
maxBytes?: number;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
body: Record<string, unknown>;
|
|
71
|
+
maxAge: number | null;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* A JSON object answered by a host an Owner chose, read to at most maxBytes (the declared length
|
|
75
|
+
* first, then as it streams), so a provider cannot make the server buffer an endless answer.
|
|
76
|
+
*/
|
|
77
|
+
export declare function slotlockReadJsonBody(response: Response, maxBytes: number): Promise<Record<string, unknown>>;
|
|
78
|
+
/** An issuer's endpoints, as its discovery document gave them when an Owner saved the connection. */
|
|
79
|
+
export interface SlotlockOidcProvider {
|
|
80
|
+
issuer: string;
|
|
81
|
+
authorizationEndpoint: string;
|
|
82
|
+
tokenEndpoint: string;
|
|
83
|
+
jwksUri: string;
|
|
84
|
+
algorithms: SlotlockOidcAlgorithm[];
|
|
85
|
+
/** RFC 9207: the issuer returns `iss` with the code, and Slotlock then checks it. */
|
|
86
|
+
issParameter: boolean;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* An issuer's discovery document (OpenID Connect Discovery 1.0): its `issuer` must be exactly the
|
|
90
|
+
* one entered, its endpoints https on public host names, and it must sign with an algorithm Slotlock
|
|
91
|
+
* verifies.
|
|
92
|
+
*/
|
|
93
|
+
export declare function discoverSlotlockOidc(issuer: string, options?: {
|
|
94
|
+
fetch?: typeof fetch;
|
|
95
|
+
}): Promise<SlotlockOidcProvider>;
|
|
96
|
+
/**
|
|
97
|
+
* An Entra ID tenant's v2.0 issuer, pinned to its GUID: never `common`, `organizations`,
|
|
98
|
+
* `consumers`, a domain name or the consumer tenant, so the multi-tenant `{tenantid}` issuer never
|
|
99
|
+
* exists in Slotlock. Null when the tenant is not one.
|
|
100
|
+
*/
|
|
101
|
+
export declare function slotlockEntraIssuer(tenant: string): string | null;
|
|
102
|
+
/** Google's issuer, in the two spellings it documents. */
|
|
103
|
+
export declare const SLOTLOCK_GOOGLE_ISSUERS: readonly string[];
|
|
104
|
+
/**
|
|
105
|
+
* Issuers' keys, by JWKS URL: kept for the answer's max-age (5 minutes to 24 hours). A key id not
|
|
106
|
+
* among them is fetched again at most once a minute per URL, one fetch at a time; while the URL
|
|
107
|
+
* fails, a set fetched within 24 hours still serves the keys it holds. At most `maxUrls` URLs and 20
|
|
108
|
+
* keys each are kept, only the fields verifying needs: the URLs are Owners' to choose.
|
|
109
|
+
*/
|
|
110
|
+
export declare function createSlotlockJwksCache(options: {
|
|
111
|
+
fetchJson?: (url: string) => Promise<{
|
|
112
|
+
body: Record<string, unknown>;
|
|
113
|
+
maxAge: number | null;
|
|
114
|
+
}>;
|
|
115
|
+
fetch?: typeof fetch;
|
|
116
|
+
now?: () => number;
|
|
117
|
+
/** How many JWKS URLs are kept (100): the least recently used goes first. */
|
|
118
|
+
maxUrls?: number;
|
|
119
|
+
}): {
|
|
120
|
+
/** The key `kid` names at `url` for `alg`, or null. */
|
|
121
|
+
key(url: string, kid: string | null, alg: SlotlockOidcAlgorithm): Promise<CryptoKey | null>;
|
|
122
|
+
/** A lookup bound to one JWKS URL, for verifySlotlockIdToken. */
|
|
123
|
+
lookup(url: string): SlotlockKeyLookup;
|
|
124
|
+
};
|
|
125
|
+
export type SlotlockJwksCache = ReturnType<typeof createSlotlockJwksCache>;
|
|
126
|
+
/**
|
|
127
|
+
* Seals and opens a team's client secret: AES-256-GCM under a key derived (HKDF-SHA-256) from
|
|
128
|
+
* SLOTLOCK_SSO_SECRET, a random 12-byte IV each time, and the team and connection as associated
|
|
129
|
+
* data, so a sealed secret moved to another row does not open.
|
|
130
|
+
*/
|
|
131
|
+
export declare function createSlotlockSecretBox(secret: string): {
|
|
132
|
+
seal(plaintext: string, associated: string): Promise<string>;
|
|
133
|
+
open(sealed: string, associated: string): Promise<string | null>;
|
|
134
|
+
};
|
|
135
|
+
export type SlotlockSecretBox = ReturnType<typeof createSlotlockSecretBox>;
|