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.
Files changed (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. 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
- capped: rollCapped || eventsLeft || bookingsLeft || changesLeft || approvals.expired === 100,
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>;