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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
@@ -59,6 +59,7 @@
59
59
  "dependencies": {
60
60
  "ical.js": "2.2.1",
61
61
  "rrule": "2.8.1",
62
+ "tldts": "7.4.19",
62
63
  "zod": "4.6.7"
63
64
  },
64
65
  "peerDependencies": {
package/ranges.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { type SlotlockBookingRules } from './booking-rules.js';
2
+ import { type SlotlockRankedException } from './hours.js';
3
+ import type { Interval, WeeklyAvailabilityRule } from './types.js';
4
+ /** Candidates one resource's search judges before it gives up. */
5
+ export declare const SLOTLOCK_MAX_RANGE_CANDIDATES = 5000;
6
+ export interface SlotlockRangeSearch {
7
+ /** Occupied time on the resource (other bookings with their buffers). */
8
+ busy: readonly Interval[];
9
+ rules: SlotlockBookingRules | undefined;
10
+ timezone: string;
11
+ hours: readonly WeeklyAvailabilityRule[];
12
+ exceptions?: readonly SlotlockRankedException[];
13
+ now: Date;
14
+ /** The start lies from `pickup.start` to `pickup.end`, both included. */
15
+ pickup: Interval;
16
+ /** The end lies from `return.start` to `return.end`, both included (or give `days`). */
17
+ return?: Interval;
18
+ /** The end is the start's local time this many days later. */
19
+ days?: number;
20
+ }
21
+ /** The instant `days` local days after `instant`, at the same wall time (a clock change moves it). */
22
+ export declare function sameTimeDaysLater(instant: Date, days: number, timezone: string): Date;
23
+ /**
24
+ * The earliest range the search allows, or null. Search honours hours always, as slot search does:
25
+ * a range from one local day to another starts and ends inside the hours, one within a day lies
26
+ * inside them. Bounded by SLOTLOCK_MAX_RANGE_CANDIDATES judged candidates.
27
+ */
28
+ export declare function findEarliestRange(search: SlotlockRangeSearch): Interval | null;
package/ranges.js ADDED
@@ -0,0 +1,135 @@
1
+ // Hires across days (RFC 0004 B3): the earliest range on one resource that starts in a pick-up
2
+ // window and ends a number of local days later, or in a return window, free throughout with its
3
+ // buffers and allowed by the resource's booking rules. Pure: the caller reads the busy time, the
4
+ // hours and the exceptions; the booking rules' own judge decides each candidate.
5
+ import { bookingBufferAfterMs, bookingBufferBeforeMs, bookingRuleBroken, hoursApplyOf, isMultiDay, nextStepStart, withinDayWindows, } from './booking-rules.js';
6
+ import { mergeIntervals } from './engine.js';
7
+ import { openWindows } from './hours.js';
8
+ import { createTimeZoneConverter, zonedOffsetMinutes } from './timezone.js';
9
+ const MINUTE_MS = 60_000;
10
+ const DAY_MS = 86_400_000;
11
+ /** Candidates one resource's search judges before it gives up. */
12
+ export const SLOTLOCK_MAX_RANGE_CANDIDATES = 5_000;
13
+ /** The instant `days` local days after `instant`, at the same wall time (a clock change moves it). */
14
+ export function sameTimeDaysLater(instant, days, timezone) {
15
+ const offset = zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
16
+ return createTimeZoneConverter(timezone).wallFrameToUtc(instant.getTime() + offset + days * DAY_MS);
17
+ }
18
+ /**
19
+ * The earliest range the search allows, or null. Search honours hours always, as slot search does:
20
+ * a range from one local day to another starts and ends inside the hours, one within a day lies
21
+ * inside them. Bounded by SLOTLOCK_MAX_RANGE_CANDIDATES judged candidates.
22
+ */
23
+ export function findEarliestRange(search) {
24
+ const { timezone, now } = search;
25
+ // Judged as a write is, inside the hours always: as the write is held to them where it is
26
+ // (enforceHours), else at a hire's start and end, what a hire needs.
27
+ const rules = {
28
+ ...(search.rules ?? {}),
29
+ enforceHours: true,
30
+ hoursApply: search.rules?.hoursApply ?? (search.rules?.enforceHours === true ? hoursApplyOf(search.rules) : 'ends'),
31
+ };
32
+ const before = bookingBufferBeforeMs(rules);
33
+ const after = bookingBufferAfterMs(rules);
34
+ const busy = mergeIntervals([...search.busy]);
35
+ const lastEnd = search.return
36
+ ? search.return.end.getTime()
37
+ : sameTimeDaysLater(search.pickup.end, search.days ?? 1, timezone).getTime() + DAY_MS;
38
+ const open = openWindows({
39
+ hours: search.hours,
40
+ ...(search.exceptions ? { exceptions: search.exceptions } : {}),
41
+ timezone,
42
+ window: { start: new Date(search.pickup.start.getTime() - DAY_MS), end: new Date(lastEnd + DAY_MS) },
43
+ });
44
+ const isOpen = (instant, closed = false) => open.some((part) => part.start.getTime() <= instant && (closed ? instant <= part.end.getTime() : instant < part.end.getTime()));
45
+ const nextOpen = (instant) => open.find((part) => part.end.getTime() > instant)?.start.getTime() ?? null;
46
+ const step = rules.slotStepMinutes;
47
+ const offset = step === undefined ? 0 : (rules.stepOffsetMinutes ?? 0) % step;
48
+ const align = (instant) => step === undefined
49
+ ? Math.ceil(instant / MINUTE_MS) * MINUTE_MS
50
+ : nextStepStart(new Date(instant), step, timezone, offset).getTime();
51
+ const earliest = Math.max(search.pickup.start.getTime(), now.getTime() + (rules.minNoticeMinutes ?? 0) * MINUTE_MS);
52
+ const latest = Math.min(search.pickup.end.getTime(), rules.maxAdvanceDays === undefined ? Number.POSITIVE_INFINITY : now.getTime() + rules.maxAdvanceDays * DAY_MS);
53
+ const blocker = (start, end) => busy.find((taken) => taken.start.getTime() < end + after && taken.end.getTime() > start - before);
54
+ /** The earliest end in the return window a range from `start` may have, or null. */
55
+ const endFor = (start) => {
56
+ if (search.days !== undefined)
57
+ return sameTimeDaysLater(new Date(start), search.days, timezone).getTime();
58
+ const window = search.return;
59
+ const lengths = rules.allowedDurations;
60
+ if (lengths) {
61
+ for (const minutes of lengths) {
62
+ const end = start + minutes * MINUTE_MS;
63
+ if (end >= window.start.getTime() && end <= window.end.getTime() && returnable(start, end))
64
+ return end;
65
+ }
66
+ return null;
67
+ }
68
+ let end = Math.max(window.start.getTime(), start + Math.max(1, rules.minDurationMinutes ?? 1) * MINUTE_MS);
69
+ end = Math.ceil(end / MINUTE_MS) * MINUTE_MS;
70
+ for (let tries = 0; end <= window.end.getTime() && tries < 3 * 1_440; tries++) {
71
+ if (rules.maxDurationMinutes !== undefined && end - start > rules.maxDurationMinutes * MINUTE_MS)
72
+ return null;
73
+ if (returnable(start, end))
74
+ return end;
75
+ // On to the next return time or opening, else the next minute.
76
+ const reopening = isOpen(end, true) ? null : nextOpen(end);
77
+ end = reopening !== null && reopening > end ? reopening : end + MINUTE_MS;
78
+ }
79
+ return null;
80
+ };
81
+ /** Whether a range may end at `end`: inside the hours at its end, and in a return time for a hire. */
82
+ const returnable = (start, end) => isOpen(end, true) &&
83
+ (!rules.returnTimes ||
84
+ !isMultiDay({ start: new Date(start), end: new Date(end) }, timezone) ||
85
+ withinDayWindows(new Date(end), rules.returnTimes, timezone, true));
86
+ /** The next start of a pick-up time at or after `instant`, by the local clock. */
87
+ const nextPickup = (instant) => {
88
+ const windows = rules.pickupTimes ?? [];
89
+ const local = ((((instant + zonedOffsetMinutes(new Date(instant), timezone) * MINUTE_MS) / MINUTE_MS) % 1_440) + 1_440) % 1_440;
90
+ const ahead = Math.min(...windows.map((window) => (((window.startMinutes - local) % 1_440) + 1_440) % 1_440));
91
+ return instant + Math.max(1, Math.round(ahead)) * MINUTE_MS;
92
+ };
93
+ let candidate = align(earliest);
94
+ for (let judged = 0; candidate <= latest && judged < SLOTLOCK_MAX_RANGE_CANDIDATES; judged++) {
95
+ // Start inside the hours, and inside a pick-up time.
96
+ if (!isOpen(candidate)) {
97
+ const reopening = nextOpen(candidate);
98
+ if (reopening === null)
99
+ return null;
100
+ candidate = align(Math.max(reopening, candidate + 1));
101
+ continue;
102
+ }
103
+ const end = endFor(candidate);
104
+ if (end !== null &&
105
+ rules.pickupTimes &&
106
+ isMultiDay({ start: new Date(candidate), end: new Date(end) }, timezone) &&
107
+ !withinDayWindows(new Date(candidate), rules.pickupTimes, timezone)) {
108
+ candidate = align(nextPickup(candidate));
109
+ continue;
110
+ }
111
+ if (end !== null && end > candidate) {
112
+ const taken = blocker(candidate, end);
113
+ if (taken) {
114
+ candidate = align(Math.max(taken.end.getTime() + before, candidate + 1));
115
+ continue;
116
+ }
117
+ const range = { start: new Date(candidate), end: new Date(end) };
118
+ if (bookingRuleBroken({
119
+ rules,
120
+ timezone,
121
+ hours: search.hours,
122
+ ...(search.exceptions ? { exceptions: search.exceptions } : {}),
123
+ interval: range,
124
+ now,
125
+ }) === null) {
126
+ return range;
127
+ }
128
+ }
129
+ else if (search.return && candidate + (rules.minDurationMinutes ?? 0) * MINUTE_MS > search.return.end.getTime()) {
130
+ return null;
131
+ }
132
+ candidate = align(candidate + MINUTE_MS);
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,19 @@
1
+ import type { SlotlockResource } from './types.js';
2
+ /** The longest search, and the most words one may have once read. */
3
+ export declare const SLOTLOCK_RESOURCE_SEARCH_MAX = 200;
4
+ /** What a search looks at: a resource as listResources gives it. */
5
+ export type SlotlockSearchableResource = Pick<SlotlockResource, 'id' | 'externalRef' | 'name' | 'description' | 'kind' | 'labels' | 'locationName' | 'location'>;
6
+ /**
7
+ * A search's words, or `invalid_query` when it is empty, longer than 200 characters or more than 12
8
+ * words. Filler ("the", "my") is dropped when other words remain.
9
+ */
10
+ export declare function slotlockResourceSearchWords(query: string): string[];
11
+ /**
12
+ * The resources that match a search's words, best first, each with its score (0 to 1). A resource is
13
+ * kept when at least half of the words find it somewhere; one that every word names exactly scores 1.
14
+ * Ties keep the order the resources were given in.
15
+ */
16
+ export declare function slotlockSearchResources<T extends SlotlockSearchableResource>(resources: readonly T[], words: readonly string[]): {
17
+ resource: T;
18
+ score: number;
19
+ }[];
@@ -0,0 +1,153 @@
1
+ /** The longest search, and the most words one may have once read. */
2
+ export const SLOTLOCK_RESOURCE_SEARCH_MAX = 200;
3
+ const MAX_WORDS = 12;
4
+ /** Words that say nothing about which resource is meant, when others do. */
5
+ const FILLER = new Set(['the', 'a', 'an', 'my', 'our', 'your', 'of', 'for', 'at', 'in', 'on', 'to', 'and', 'with']);
6
+ /** Lower case, accents and other marks taken off: "Zürich" reads as "zurich". */
7
+ const fold = (text) => text.normalize('NFKD').replace(/\p{M}+/gu, '').toLowerCase();
8
+ const wordsOf = (text) => fold(text).split(/[^\p{L}\p{N}]+/u).filter((word) => word !== '');
9
+ /**
10
+ * A search's words, or `invalid_query` when it is empty, longer than 200 characters or more than 12
11
+ * words. Filler ("the", "my") is dropped when other words remain.
12
+ */
13
+ export function slotlockResourceSearchWords(query) {
14
+ const trimmed = typeof query === 'string' ? query.trim() : '';
15
+ if (trimmed === '' || [...trimmed].length > SLOTLOCK_RESOURCE_SEARCH_MAX)
16
+ throw invalidQuery();
17
+ // Counted as written: repeating a word does not make room for more.
18
+ const written = wordsOf(trimmed);
19
+ if (written.length > MAX_WORDS)
20
+ throw invalidQuery();
21
+ const words = [...new Set(written)];
22
+ const meaningful = words.filter((word) => !FILLER.has(word));
23
+ // Only symbols ("%", "#"): looked for as written.
24
+ if (words.length === 0)
25
+ return [fold(trimmed)];
26
+ return meaningful.length > 0 ? meaningful : words;
27
+ }
28
+ function invalidQuery() {
29
+ return Object.assign(new Error(`Slotlock resource search must be 1-${String(SLOTLOCK_RESOURCE_SEARCH_MAX)} characters of at most ${String(MAX_WORDS)} words`), { code: 'invalid_query' });
30
+ }
31
+ /** Edits between two words (a swap of neighbours is one), or more than `most` once past it. */
32
+ function distance(a, b, most) {
33
+ if (Math.abs(a.length - b.length) > most)
34
+ return most + 1;
35
+ let before = [];
36
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
37
+ for (let i = 1; i <= a.length; i += 1) {
38
+ const current = [i];
39
+ let smallest = i;
40
+ for (let j = 1; j <= b.length; j += 1) {
41
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
42
+ let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
43
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
44
+ value = Math.min(value, before[j - 2] + 1);
45
+ }
46
+ current[j] = value;
47
+ smallest = Math.min(smallest, value);
48
+ }
49
+ if (smallest > most)
50
+ return most + 1;
51
+ before = previous;
52
+ previous = current;
53
+ }
54
+ return previous[b.length];
55
+ }
56
+ const DIGITS = /^\p{N}+$/u;
57
+ /**
58
+ * The most edit distances one search computes: past it, matching is exact, by prefix and by words
59
+ * run together only. A tenant's resources (up to 5,000, each with a long description) cannot make
60
+ * one search cost seconds.
61
+ */
62
+ const FUZZY_BUDGET = 20_000;
63
+ /** A description is looked through by its first words only. */
64
+ const DESCRIPTION_WORDS = 60;
65
+ /**
66
+ * How well one word of a search names a field (0 to 1), given the field's words and those run
67
+ * together. Typos are forgiven only where `fuzzy`, and only while `budget` lasts.
68
+ */
69
+ function wordScore(word, field, fuzzy, budget) {
70
+ let best = 0;
71
+ for (const candidate of field.words) {
72
+ if (candidate === word)
73
+ return 1;
74
+ if (word.length >= 2 && candidate.startsWith(word))
75
+ best = Math.max(best, 0.85);
76
+ }
77
+ // "mercedesbenz" for "Mercedes-Benz", "car17" for "car-17".
78
+ if (word.length >= 3 && field.joined.includes(word))
79
+ best = Math.max(best, 0.75);
80
+ if (fuzzy && best < 0.7 && !DIGITS.test(word) && word.length >= 4 && word.length <= 24) {
81
+ const most = word.length >= 8 ? 2 : 1;
82
+ for (const candidate of field.words) {
83
+ if (DIGITS.test(candidate) || candidate.length > 32)
84
+ continue;
85
+ if (budget.left <= 0)
86
+ break;
87
+ budget.left -= 2;
88
+ const edits = distance(word, candidate, most);
89
+ if (edits <= most)
90
+ best = Math.max(best, edits === 1 ? 0.7 : 0.6);
91
+ // A typo in a word cut short: "merced" for "mercedes" is a prefix; "mercad" is one edit off one.
92
+ if (candidate.length > word.length && distance(word, candidate.slice(0, word.length), 1) <= 1) {
93
+ best = Math.max(best, 0.6);
94
+ }
95
+ }
96
+ }
97
+ return best;
98
+ }
99
+ /** How much a match in each field counts (a name most, a description least), and whether typos are forgiven there. */
100
+ const FIELDS = [
101
+ ['name', 1, true],
102
+ ['ref', 0.95, true],
103
+ ['labels', 0.7, true],
104
+ ['kind', 0.6, true],
105
+ ['location', 0.6, true],
106
+ ['description', 0.4, false],
107
+ ];
108
+ const indexed = (text, most = Number.POSITIVE_INFINITY) => {
109
+ const words = text ? wordsOf(text).slice(0, most) : [];
110
+ return { words, joined: words.join('') };
111
+ };
112
+ /**
113
+ * The resources that match a search's words, best first, each with its score (0 to 1). A resource is
114
+ * kept when at least half of the words find it somewhere; one that every word names exactly scores 1.
115
+ * Ties keep the order the resources were given in.
116
+ */
117
+ export function slotlockSearchResources(resources, words) {
118
+ if (words.length === 0)
119
+ return [];
120
+ const budget = { left: FUZZY_BUDGET };
121
+ const scored = [];
122
+ resources.forEach((resource, at) => {
123
+ const fields = {
124
+ name: indexed(resource.name),
125
+ ref: indexed(resource.externalRef),
126
+ labels: indexed(resource.labels?.join(' ')),
127
+ kind: indexed(resource.kind),
128
+ location: indexed(resource.locationName ?? resource.location),
129
+ description: indexed(resource.description, DESCRIPTION_WORDS),
130
+ };
131
+ let total = 0;
132
+ let found = 0;
133
+ for (const word of words) {
134
+ let best = 0;
135
+ for (const [field, weight, fuzzy] of FIELDS)
136
+ best = Math.max(best, weight * wordScore(word, fields[field], fuzzy, budget));
137
+ // A search of symbols only ("%"): looked for as written in the name and reference.
138
+ if (best === 0 && !/[\p{L}\p{N}]/u.test(word)) {
139
+ if (fold(`${resource.name ?? ''} ${resource.externalRef ?? ''}`).includes(word))
140
+ best = 1;
141
+ }
142
+ total += best;
143
+ if (best >= 0.3)
144
+ found += 1;
145
+ }
146
+ const score = total / words.length;
147
+ if (found * 2 >= words.length && score >= 0.3)
148
+ scored.push({ resource, score, at });
149
+ });
150
+ return scored
151
+ .sort((a, b) => b.score - a.score || a.at - b.at)
152
+ .map(({ resource, score }) => ({ resource, score: Math.round(score * 100) / 100 }));
153
+ }
package/rest-api.js CHANGED
@@ -14,7 +14,14 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
14
14
  operation: 'slotlock_find_available_slots',
15
15
  refusals: [404],
16
16
  },
17
+ {
18
+ method: 'GET',
19
+ path: '/v1/available-ranges',
20
+ operation: 'slotlock_find_available_ranges',
21
+ refusals: [404, 409],
22
+ },
17
23
  { method: 'POST', path: '/v1/slot-holds', operation: 'slotlock_hold_slots', refusals: [409] },
24
+ { method: 'POST', path: '/v1/slot-holds/together', operation: 'slotlock_hold_together', refusals: [404, 409] },
18
25
  { method: 'POST', path: '/v1/bundles', operation: 'slotlock_book_together', refusals: [404, 409] },
19
26
  { method: 'POST', path: '/v1/pool-bookings', operation: 'slotlock_book_pool', refusals: [404, 409] },
20
27
  {
package/rules.d.ts CHANGED
@@ -9,6 +9,8 @@ export declare const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
9
9
  * many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
10
10
  */
11
11
  export declare const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10080;
12
+ /** The most busy intervals one calendar source may report for one window (RFC 0004 C1). */
13
+ export declare const SLOTLOCK_SOURCE_BUSY_MAX = 5000;
12
14
  export declare function invalidWindowError(message?: string): Error & {
13
15
  code: 'invalid_window';
14
16
  };
package/rules.js CHANGED
@@ -17,6 +17,8 @@ const MAX_SEARCH_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
17
17
  * many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
18
18
  */
19
19
  export const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10_080;
20
+ /** The most busy intervals one calendar source may report for one window (RFC 0004 C1). */
21
+ export const SLOTLOCK_SOURCE_BUSY_MAX = 5_000;
20
22
  export function invalidWindowError(message = 'Slotlock window must contain two finite dates with end > start') {
21
23
  return Object.assign(new Error(message), { code: 'invalid_window' });
22
24
  }
@@ -0,0 +1,19 @@
1
+ import type { SlotlockAuditEventInput } from './store.js';
2
+ import type { SlotlockWorkspaceScim } from './workspace-scim.js';
3
+ /** What the endpoint needs from its host. */
4
+ export interface SlotlockScimServerOptions {
5
+ scim: SlotlockWorkspaceScim;
6
+ /** Where `scim/v2` lives: the dashboard's public root. */
7
+ publicRoot: string;
8
+ documentationUri: string;
9
+ /** Records an event in a team's audit log. */
10
+ record?: (tenantRef: string, event: SlotlockAuditEventInput) => Promise<void>;
11
+ /** A person's membership ended by the directory: their keys go where the team chose. */
12
+ deactivated?: (tenantRef: string, person: string, keyPolicy: 'keep' | 'revoke') => Promise<number>;
13
+ newId?: () => string;
14
+ }
15
+ /**
16
+ * One SCIM request. `path` is what follows `scim/v2/`: `<team id>/<resource>[/<id>]`. Answers 401
17
+ * without a token for the team the address names.
18
+ */
19
+ export declare function handleSlotlockScimRequest(request: Request, path: string, options: SlotlockScimServerOptions): Promise<Response>;
package/scim-server.js ADDED
@@ -0,0 +1,243 @@
1
+ // A team's SCIM 2.0 endpoint (teams phase 5.6–5.7): `<root>/scim/v2/<team id>/…`, on the dashboard's
2
+ // Worker and on the Node server alike. The token decides the team, and must be the one the address
3
+ // names; session cookies never work here, nor SCIM tokens on the dashboard. Requests are parsed and
4
+ // normalised by scim.ts, kept through workspace-scim.ts, and each write is one event in the team's
5
+ // audit log, as its token's (never a body: Okta sends a placeholder password).
6
+ import { randomUUID } from 'node:crypto';
7
+ import { SLOTLOCK_SCIM_CONTENT_TYPE, SlotlockScimError, slotlockScimErrorBody, slotlockScimFilter, slotlockScimGroupFromBody, slotlockScimGroupResource, slotlockScimList, slotlockScimPage, slotlockScimPatchGroup, slotlockScimPatchOperations, slotlockScimPatchUser, slotlockScimResourceTypes, slotlockScimSchemas, slotlockScimServiceProviderConfig, slotlockScimUserFromBody, slotlockScimUserResource, } from './scim.js';
8
+ const USER_FILTERS = ['username', 'externalid', 'id', 'emails[type eq "work"].value', 'emails.value'];
9
+ const GROUP_FILTERS = ['displayname', 'externalid', 'id'];
10
+ const MAX_BODY_BYTES = 262_144;
11
+ const json = (status, body, headers = {}) => new Response(status === 204 ? null : JSON.stringify(body), {
12
+ status,
13
+ headers: { 'content-type': `${SLOTLOCK_SCIM_CONTENT_TYPE}; charset=utf-8`, 'cache-control': 'no-store', ...headers },
14
+ });
15
+ const failure = (status, scimType, detail) => json(status, slotlockScimErrorBody(status, scimType, detail));
16
+ /** How a refusal from the database answers. */
17
+ function refused(refusal) {
18
+ switch (refusal) {
19
+ case 'uniqueness':
20
+ return failure(409, 'uniqueness', 'A resource with that name exists');
21
+ case 'domain_not_verified':
22
+ return failure(400, 'invalidValue', 'The address is not at a domain this team has verified');
23
+ case 'owner_protected':
24
+ return failure(400, 'mutability', 'Owners are managed in Slotlock, not by the directory');
25
+ case 'invalid_member':
26
+ return failure(400, 'invalidValue', 'A member is not one of this team’s users');
27
+ case 'not_found':
28
+ return failure(404, null, 'Not found');
29
+ default:
30
+ return failure(400, 'invalidValue', refusal);
31
+ }
32
+ }
33
+ /** The lookup a filter asks for, as workspace-scim.ts takes it. */
34
+ function lookupOf(query, allowed) {
35
+ const filter = query.get('filter');
36
+ if (filter === null || filter.trim() === '')
37
+ return null;
38
+ const [first, ...rest] = slotlockScimFilter(filter, allowed);
39
+ if (!first || rest.length > 0)
40
+ throw new SlotlockScimError(400, 'invalidFilter', 'Slotlock filters by one attribute');
41
+ const attribute = first.attribute.startsWith('emails') ? 'email' : first.attribute;
42
+ return { attribute, value: first.value };
43
+ }
44
+ async function bodyOf(request) {
45
+ const type = (request.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase();
46
+ if (type !== SLOTLOCK_SCIM_CONTENT_TYPE && type !== 'application/json')
47
+ throw new SlotlockScimError(415, null, 'Send application/scim+json');
48
+ // Counted in bytes as it arrives, never read past the cap: a token's holder cannot make the server
49
+ // buffer an endless body.
50
+ const tooLarge = () => new SlotlockScimError(413, null, 'The request is too large');
51
+ const declared = Number(request.headers.get('content-length'));
52
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
53
+ await request.body?.cancel().catch(() => undefined);
54
+ throw tooLarge();
55
+ }
56
+ const chunks = [];
57
+ let size = 0;
58
+ const reader = request.body?.getReader();
59
+ if (reader) {
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ size += value.byteLength;
65
+ if (size > MAX_BODY_BYTES) {
66
+ await reader.cancel().catch(() => undefined);
67
+ throw tooLarge();
68
+ }
69
+ chunks.push(value);
70
+ }
71
+ }
72
+ const bytes = new Uint8Array(size);
73
+ let offset = 0;
74
+ for (const chunk of chunks) {
75
+ bytes.set(chunk, offset);
76
+ offset += chunk.byteLength;
77
+ }
78
+ let text;
79
+ try {
80
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
81
+ }
82
+ catch {
83
+ throw new SlotlockScimError(400, 'invalidSyntax', 'The body is not UTF-8');
84
+ }
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ throw new SlotlockScimError(400, 'invalidSyntax', 'The body is not JSON');
90
+ }
91
+ }
92
+ /**
93
+ * One SCIM request. `path` is what follows `scim/v2/`: `<team id>/<resource>[/<id>]`. Answers 401
94
+ * without a token for the team the address names.
95
+ */
96
+ export async function handleSlotlockScimRequest(request, path, options) {
97
+ const [teamId = '', resource = '', id, ...extra] = path.split('/');
98
+ const authorization = request.headers.get('authorization') ?? '';
99
+ const token = /^Bearer\s+(\S+)$/i.exec(authorization)?.[1] ?? '';
100
+ const authenticated = await options.scim.authenticate(token, teamId);
101
+ if ('refusal' in authenticated) {
102
+ // Another team's token at this address: a security event, in the token's own team's log.
103
+ if (authenticated.refusal === 'wrong_workspace' && authenticated.tenantRef && options.record) {
104
+ await options.record(authenticated.tenantRef, {
105
+ action: 'scim.token_refused',
106
+ actorType: 'system',
107
+ actorRef: `scim_token:${authenticated.prefix ?? 'unknown'}`,
108
+ outcome: 'denied',
109
+ reason: 'wrong_workspace',
110
+ }).catch(() => undefined);
111
+ }
112
+ return json(401, slotlockScimErrorBody(401, null, 'A SCIM token for this team is needed'), { 'www-authenticate': 'Bearer' });
113
+ }
114
+ const { tenantRef, prefix, keyPolicy } = authenticated;
115
+ const base = `${options.publicRoot}/scim/v2/${teamId.toLowerCase()}`;
116
+ const actor = { actorType: 'system', actorRef: `scim_token:${prefix}` };
117
+ const record = async (event) => {
118
+ await options.record?.(tenantRef, { ...event, ...actor }).catch(() => undefined);
119
+ };
120
+ const newId = options.newId ?? randomUUID;
121
+ const url = new URL(request.url);
122
+ const method = request.method.toUpperCase();
123
+ if (extra.length > 0)
124
+ return failure(404, null, 'Not found');
125
+ try {
126
+ if (resource === 'ServiceProviderConfig' && method === 'GET')
127
+ return json(200, slotlockScimServiceProviderConfig(options.documentationUri));
128
+ if (resource === 'ResourceTypes' && method === 'GET')
129
+ return json(200, slotlockScimResourceTypes(base));
130
+ if (resource === 'Schemas' && method === 'GET')
131
+ return json(200, slotlockScimSchemas(base));
132
+ if (resource === 'Users') {
133
+ const meta = (user) => ({ created: user.createdAt, lastModified: user.updatedAt, location: `${base}/Users/${user.id}` });
134
+ const one = async (userId) => (await options.scim.users(tenantRef, null, 1, 1, userId)).users[0];
135
+ if (id === undefined && method === 'GET') {
136
+ const { startIndex, count } = slotlockScimPage(url.searchParams);
137
+ const found = await options.scim.users(tenantRef, lookupOf(url.searchParams, USER_FILTERS), startIndex, count);
138
+ return json(200, slotlockScimList(found.users.map((user) => slotlockScimUserResource(user.id, user, meta(user))), found.total, startIndex));
139
+ }
140
+ if (id === undefined && method === 'POST') {
141
+ const user = slotlockScimUserFromBody(await bodyOf(request));
142
+ const saved = await options.scim.putUser(tenantRef, null, newId(), user);
143
+ if ('refusal' in saved)
144
+ return refused(saved.refusal);
145
+ await record({ action: 'scim.user_created', targetType: 'scim_user', targetRef: saved.id });
146
+ const made = await one(saved.id);
147
+ return made ? json(201, slotlockScimUserResource(made.id, made, meta(made)), { location: `${base}/Users/${made.id}` }) : failure(404, null, 'Not found');
148
+ }
149
+ if (id === undefined)
150
+ return failure(405, null, 'Not allowed');
151
+ const current = await one(id);
152
+ if (!current)
153
+ return failure(404, null, 'Not found');
154
+ if (method === 'GET')
155
+ return json(200, slotlockScimUserResource(current.id, current, meta(current)));
156
+ if (method === 'DELETE') {
157
+ const deleted = await options.scim.deleteUser(tenantRef, id);
158
+ if (deleted.refusal)
159
+ return refused(deleted.refusal);
160
+ await record({ action: 'scim.user_deleted', targetType: 'scim_user', targetRef: id });
161
+ if (deleted.person)
162
+ await options.deactivated?.(tenantRef, deleted.person, keyPolicy);
163
+ return json(204, null);
164
+ }
165
+ if (method === 'PUT' || method === 'PATCH') {
166
+ const user = method === 'PUT'
167
+ ? slotlockScimUserFromBody(await bodyOf(request))
168
+ : slotlockScimPatchUser(current, current.id, slotlockScimPatchOperations(await bodyOf(request)));
169
+ const saved = await options.scim.putUser(tenantRef, id, newId(), user);
170
+ if ('refusal' in saved) {
171
+ if (saved.refusal === 'owner_protected') {
172
+ await record({ action: 'scim.user_refused', targetType: 'scim_user', targetRef: id, outcome: 'denied', reason: 'owner_protected' });
173
+ }
174
+ return refused(saved.refusal);
175
+ }
176
+ if (saved.membership === 'deactivated' && saved.person) {
177
+ const revoked = (await options.deactivated?.(tenantRef, saved.person, keyPolicy)) ?? 0;
178
+ await record({ action: 'scim.user_deactivated', targetType: 'person', targetRef: saved.person, metadata: { keys: keyPolicy, revoked } });
179
+ }
180
+ else if (saved.membership === 'activated' && saved.person) {
181
+ await record({ action: 'scim.user_reactivated', targetType: 'person', targetRef: saved.person });
182
+ }
183
+ else {
184
+ await record({ action: 'scim.user_updated', targetType: 'scim_user', targetRef: id });
185
+ }
186
+ const updated = await one(id);
187
+ return updated ? json(200, slotlockScimUserResource(updated.id, updated, meta(updated))) : failure(404, null, 'Not found');
188
+ }
189
+ return failure(405, null, 'Not allowed');
190
+ }
191
+ if (resource === 'Groups') {
192
+ const meta = (group) => ({ created: group.createdAt, lastModified: group.updatedAt, location: `${base}/Groups/${group.id}` });
193
+ const withMembers = !/\bmembers\b/i.test(url.searchParams.get('excludedAttributes') ?? '');
194
+ const one = async (groupId) => (await options.scim.groups(tenantRef, null, 1, 1, groupId)).groups[0];
195
+ if (id === undefined && method === 'GET') {
196
+ const { startIndex, count } = slotlockScimPage(url.searchParams);
197
+ const found = await options.scim.groups(tenantRef, lookupOf(url.searchParams, GROUP_FILTERS), startIndex, count);
198
+ return json(200, slotlockScimList(found.groups.map((group) => slotlockScimGroupResource(group.id, group, meta(group), withMembers)), found.total, startIndex));
199
+ }
200
+ if (id === undefined && method === 'POST') {
201
+ const group = slotlockScimGroupFromBody(await bodyOf(request));
202
+ const saved = await options.scim.putGroup(tenantRef, null, newId(), group);
203
+ if ('refusal' in saved)
204
+ return refused(saved.refusal);
205
+ await record({ action: 'scim.group_created', targetType: 'scim_group', targetRef: saved.id, metadata: { members: group.members.length } });
206
+ const made = await one(saved.id);
207
+ return made ? json(201, slotlockScimGroupResource(made.id, made, meta(made)), { location: `${base}/Groups/${made.id}` }) : failure(404, null, 'Not found');
208
+ }
209
+ if (id === undefined)
210
+ return failure(405, null, 'Not allowed');
211
+ const current = await one(id);
212
+ if (!current)
213
+ return failure(404, null, 'Not found');
214
+ if (method === 'GET')
215
+ return json(200, slotlockScimGroupResource(current.id, current, meta(current), withMembers));
216
+ if (method === 'DELETE') {
217
+ const refusal = await options.scim.deleteGroup(tenantRef, id);
218
+ if (refusal)
219
+ return refused(refusal);
220
+ await record({ action: 'scim.group_deleted', targetType: 'scim_group', targetRef: id });
221
+ return json(204, null);
222
+ }
223
+ if (method === 'PUT' || method === 'PATCH') {
224
+ const group = method === 'PUT'
225
+ ? slotlockScimGroupFromBody(await bodyOf(request))
226
+ : slotlockScimPatchGroup(current, current.id, slotlockScimPatchOperations(await bodyOf(request)));
227
+ const saved = await options.scim.putGroup(tenantRef, id, newId(), group);
228
+ if ('refusal' in saved)
229
+ return refused(saved.refusal);
230
+ await record({ action: 'scim.group_updated', targetType: 'scim_group', targetRef: id, metadata: { members: group.members.length } });
231
+ const updated = await one(id);
232
+ return updated ? json(200, slotlockScimGroupResource(updated.id, updated, meta(updated), withMembers)) : failure(404, null, 'Not found');
233
+ }
234
+ return failure(405, null, 'Not allowed');
235
+ }
236
+ return failure(404, null, 'Not found');
237
+ }
238
+ catch (error) {
239
+ if (error instanceof SlotlockScimError)
240
+ return failure(error.status, error.scimType, error.message);
241
+ throw error;
242
+ }
243
+ }