slotlock 0.2.0 → 0.5.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 +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Interval, WeeklyAvailabilityRule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A resource's booking rules: what a booking made through Slotlock (a hold, a reservation, an
|
|
4
|
+
* agent's event) must satisfy, and what a search offers. Each is optional; none set is no rule.
|
|
5
|
+
* Imported calendar events are what happened elsewhere and are not checked.
|
|
6
|
+
*/
|
|
7
|
+
export interface SlotlockBookingRules {
|
|
8
|
+
/** A booking starts at least this many minutes after it is made: 0-525,600 (a year). */
|
|
9
|
+
minNoticeMinutes?: number;
|
|
10
|
+
/** A booking starts at most this many days after it is made: 1-3,660. */
|
|
11
|
+
maxAdvanceDays?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Bookings start on a step of this many minutes from local midnight, in the resource's time zone
|
|
14
|
+
* (15: on the quarter hour): 1-1,440.
|
|
15
|
+
*/
|
|
16
|
+
slotStepMinutes?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Minutes kept free after each booking made under the rule (cleaning, handover, travel), as its
|
|
19
|
+
* trailing buffer, so the database refuses a booking that would start inside it: 0-1,440. A
|
|
20
|
+
* booking made before the rule, or imported, keeps the buffer it has.
|
|
21
|
+
*/
|
|
22
|
+
turnaroundMinutes?: number;
|
|
23
|
+
/** A booking lies inside the resource's bookable hours. */
|
|
24
|
+
enforceHours?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* A booking is claimed (checked in) within this many minutes of its start, or maintenance
|
|
27
|
+
* releases it for others (and offers it to the waitlist): 1-1,440. Only bookings made once the
|
|
28
|
+
* rule is set are released.
|
|
29
|
+
*/
|
|
30
|
+
checkInMinutes?: number;
|
|
31
|
+
/**
|
|
32
|
+
* A booking lies in time every calendar sync recording coverage for the resource has read within
|
|
33
|
+
* the last day (`recordCalendarCoverage`): time a stale or failing sync leaves unproven is
|
|
34
|
+
* refused (`uncertain`), not booked over what it may hide. A resource no sync covers is certain.
|
|
35
|
+
*/
|
|
36
|
+
requireCertain?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Which rule a write broke, as its refusal names it. */
|
|
39
|
+
export type SlotlockBookingRule = 'min_notice' | 'max_advance' | 'slot_step' | 'outside_hours' | 'uncertain';
|
|
40
|
+
/** Each numeric rule's least and greatest value. */
|
|
41
|
+
export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
|
|
42
|
+
readonly minNoticeMinutes: readonly [0, 525600];
|
|
43
|
+
readonly maxAdvanceDays: readonly [1, 3660];
|
|
44
|
+
readonly slotStepMinutes: readonly [1, number];
|
|
45
|
+
readonly turnaroundMinutes: readonly [0, number];
|
|
46
|
+
readonly checkInMinutes: readonly [1, number];
|
|
47
|
+
}>;
|
|
48
|
+
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
49
|
+
export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
|
|
50
|
+
/** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
|
|
51
|
+
export declare function bookingBufferAfterMs(rules: SlotlockBookingRules | undefined, ownMs?: number): number;
|
|
52
|
+
/**
|
|
53
|
+
* The first instant at or after `from` that starts on a step in `timezone`. A step that does not
|
|
54
|
+
* divide the day starts again at local midnight. Across a clock change it moves forward until the
|
|
55
|
+
* local time is on a step.
|
|
56
|
+
*/
|
|
57
|
+
export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string): Date;
|
|
58
|
+
/**
|
|
59
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` are the resource's
|
|
60
|
+
* bookable hours (its own, or the default), read only when the rules enforce them.
|
|
61
|
+
*/
|
|
62
|
+
export declare function bookingRuleBroken(params: {
|
|
63
|
+
rules: SlotlockBookingRules | undefined;
|
|
64
|
+
timezone: string;
|
|
65
|
+
hours: readonly WeeklyAvailabilityRule[];
|
|
66
|
+
interval: Interval;
|
|
67
|
+
now: Date;
|
|
68
|
+
}): SlotlockBookingRule | null;
|
|
69
|
+
/**
|
|
70
|
+
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
71
|
+
* trailing buffer each slot keeps, and how a candidate start moves to a step. `null` when no start
|
|
72
|
+
* in the window is allowed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function bookingSearch(params: {
|
|
75
|
+
rules: SlotlockBookingRules | undefined;
|
|
76
|
+
timezone: string;
|
|
77
|
+
window: Interval;
|
|
78
|
+
now: Date;
|
|
79
|
+
bufferAfterMs?: number;
|
|
80
|
+
}): {
|
|
81
|
+
window: Interval;
|
|
82
|
+
bufferAfterMs: number;
|
|
83
|
+
align?: (start: Date) => Date;
|
|
84
|
+
} | null;
|
package/booking-rules.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { expandRules } from './rules.js';
|
|
2
|
+
import { zonedOffsetMinutes } from './timezone.js';
|
|
3
|
+
const MINUTE_MS = 60_000;
|
|
4
|
+
const DAY_MINUTES = 24 * 60;
|
|
5
|
+
/** Each numeric rule's least and greatest value. */
|
|
6
|
+
export const SLOTLOCK_BOOKING_RULE_LIMITS = Object.freeze({
|
|
7
|
+
minNoticeMinutes: [0, 525_600],
|
|
8
|
+
maxAdvanceDays: [1, 3_660],
|
|
9
|
+
slotStepMinutes: [1, DAY_MINUTES],
|
|
10
|
+
turnaroundMinutes: [0, DAY_MINUTES],
|
|
11
|
+
checkInMinutes: [1, DAY_MINUTES],
|
|
12
|
+
});
|
|
13
|
+
const LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
|
|
14
|
+
/** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
|
|
15
|
+
export function parseBookingRules(value) {
|
|
16
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
17
|
+
throw new TypeError('Slotlock booking rules must be an object');
|
|
18
|
+
}
|
|
19
|
+
const input = value;
|
|
20
|
+
const rules = {};
|
|
21
|
+
for (const key of Object.keys(input)) {
|
|
22
|
+
if (!(key in LIMITS) && key !== 'enforceHours' && key !== 'requireCertain') {
|
|
23
|
+
throw new TypeError(`Slotlock booking rules have no ${key}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const [key, [min, max]] of Object.entries(LIMITS)) {
|
|
27
|
+
const field = input[key];
|
|
28
|
+
if (field === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (typeof field !== 'number' || !Number.isSafeInteger(field) || field < min || field > max) {
|
|
31
|
+
throw new TypeError(`Slotlock booking rule ${key} must be a whole ${min}-${max}`);
|
|
32
|
+
}
|
|
33
|
+
rules[key] = field;
|
|
34
|
+
}
|
|
35
|
+
if (input.enforceHours !== undefined) {
|
|
36
|
+
if (typeof input.enforceHours !== 'boolean') {
|
|
37
|
+
throw new TypeError('Slotlock booking rule enforceHours must be true or false');
|
|
38
|
+
}
|
|
39
|
+
rules.enforceHours = input.enforceHours;
|
|
40
|
+
}
|
|
41
|
+
if (input.requireCertain !== undefined) {
|
|
42
|
+
if (typeof input.requireCertain !== 'boolean') {
|
|
43
|
+
throw new TypeError('Slotlock booking rule requireCertain must be true or false');
|
|
44
|
+
}
|
|
45
|
+
rules.requireCertain = input.requireCertain;
|
|
46
|
+
}
|
|
47
|
+
return rules;
|
|
48
|
+
}
|
|
49
|
+
/** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
|
|
50
|
+
export function bookingBufferAfterMs(rules, ownMs = 0) {
|
|
51
|
+
return Math.max(ownMs, (rules?.turnaroundMinutes ?? 0) * MINUTE_MS);
|
|
52
|
+
}
|
|
53
|
+
/** Minutes past local midnight in `timezone`, with the fraction. */
|
|
54
|
+
function localMinutes(instant, timezone) {
|
|
55
|
+
const local = instant.getTime() + zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
|
|
56
|
+
return (((local / MINUTE_MS) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
|
57
|
+
}
|
|
58
|
+
function onStep(instant, stepMinutes, timezone) {
|
|
59
|
+
const minutes = localMinutes(instant, timezone);
|
|
60
|
+
return Number.isInteger(minutes) && minutes % stepMinutes === 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The first instant at or after `from` that starts on a step in `timezone`. A step that does not
|
|
64
|
+
* divide the day starts again at local midnight. Across a clock change it moves forward until the
|
|
65
|
+
* local time is on a step.
|
|
66
|
+
*/
|
|
67
|
+
export function nextStepStart(from, stepMinutes, timezone) {
|
|
68
|
+
let candidate = from;
|
|
69
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
70
|
+
if (onStep(candidate, stepMinutes, timezone))
|
|
71
|
+
return candidate;
|
|
72
|
+
const minutes = localMinutes(candidate, timezone);
|
|
73
|
+
const next = Math.min(Math.ceil(minutes / stepMinutes + 1e-9) * stepMinutes, DAY_MINUTES);
|
|
74
|
+
const ahead = Math.max(next - minutes, 1 / 60_000);
|
|
75
|
+
candidate = new Date(Math.ceil(candidate.getTime() + ahead * MINUTE_MS));
|
|
76
|
+
}
|
|
77
|
+
return candidate;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The rule a booking of `interval` made at `now` breaks, or null. `hours` are the resource's
|
|
81
|
+
* bookable hours (its own, or the default), read only when the rules enforce them.
|
|
82
|
+
*/
|
|
83
|
+
export function bookingRuleBroken(params) {
|
|
84
|
+
const { rules, interval, now } = params;
|
|
85
|
+
if (!rules)
|
|
86
|
+
return null;
|
|
87
|
+
const start = interval.start.getTime();
|
|
88
|
+
if (rules.minNoticeMinutes !== undefined && start < now.getTime() + rules.minNoticeMinutes * MINUTE_MS) {
|
|
89
|
+
return 'min_notice';
|
|
90
|
+
}
|
|
91
|
+
if (rules.maxAdvanceDays !== undefined &&
|
|
92
|
+
start > now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS) {
|
|
93
|
+
return 'max_advance';
|
|
94
|
+
}
|
|
95
|
+
if (rules.slotStepMinutes !== undefined && !onStep(interval.start, rules.slotStepMinutes, params.timezone)) {
|
|
96
|
+
return 'slot_step';
|
|
97
|
+
}
|
|
98
|
+
if (rules.enforceHours === true) {
|
|
99
|
+
const open = expandRules([...params.hours], interval, params.timezone);
|
|
100
|
+
let covered = interval.start.getTime();
|
|
101
|
+
for (const window of open.sort((a, b) => a.start.getTime() - b.start.getTime())) {
|
|
102
|
+
if (window.start.getTime() > covered)
|
|
103
|
+
break;
|
|
104
|
+
covered = Math.max(covered, window.end.getTime());
|
|
105
|
+
if (covered >= interval.end.getTime())
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (covered < interval.end.getTime())
|
|
109
|
+
return 'outside_hours';
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* How a search honours the rules: the part of `window` a booking made at `now` may start in, the
|
|
115
|
+
* trailing buffer each slot keeps, and how a candidate start moves to a step. `null` when no start
|
|
116
|
+
* in the window is allowed.
|
|
117
|
+
*/
|
|
118
|
+
export function bookingSearch(params) {
|
|
119
|
+
const { rules, window, now } = params;
|
|
120
|
+
let start = window.start.getTime();
|
|
121
|
+
let end = window.end.getTime();
|
|
122
|
+
if (rules?.minNoticeMinutes !== undefined) {
|
|
123
|
+
start = Math.max(start, now.getTime() + rules.minNoticeMinutes * MINUTE_MS);
|
|
124
|
+
}
|
|
125
|
+
const bufferAfterMs = bookingBufferAfterMs(rules, params.bufferAfterMs ?? 0);
|
|
126
|
+
const step = rules?.slotStepMinutes;
|
|
127
|
+
const searched = { start: new Date(start), end: new Date(end) };
|
|
128
|
+
if (rules?.maxAdvanceDays !== undefined) {
|
|
129
|
+
// The latest allowed start bounds where a slot may start; it may still end after it.
|
|
130
|
+
const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
|
|
131
|
+
if (latestStart < start || end <= start)
|
|
132
|
+
return null;
|
|
133
|
+
const align = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone);
|
|
134
|
+
return {
|
|
135
|
+
window: searched,
|
|
136
|
+
bufferAfterMs,
|
|
137
|
+
align: (from) => {
|
|
138
|
+
const aligned = align ? align(from) : from;
|
|
139
|
+
// Past the latest start, no slot: move it beyond the window so it cannot fit.
|
|
140
|
+
return aligned.getTime() > latestStart ? new Date(end + 1) : aligned;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (end <= start)
|
|
145
|
+
return null;
|
|
146
|
+
return step === undefined
|
|
147
|
+
? { window: searched, bufferAfterMs }
|
|
148
|
+
: { window: searched, bufferAfterMs, align: (from) => nextStepStart(from, step, params.timezone) };
|
|
149
|
+
}
|
package/config.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export declare function readServerUrl(env: SlotlockEnv, name: string, fallback?:
|
|
|
20
20
|
* an absolute UNTIL) is a startup error instead of silently making every resource unbookable.
|
|
21
21
|
*/
|
|
22
22
|
export declare function readAvailability(env: SlotlockEnv): WeeklyAvailabilityRule[];
|
|
23
|
+
/**
|
|
24
|
+
* A dashboard name (SLOTLOCK_DASHBOARD_NAME): 1-60 characters, none of them a control, format
|
|
25
|
+
* (bidi, zero-width) or line separator character, `<` or `>`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SLOTLOCK_DASHBOARD_NAME_PATTERN: RegExp;
|
|
23
28
|
/** The GitHub sign-in dashboard (dashboard.ts), served at `<publicUrl>/dashboard` when configured. */
|
|
24
29
|
export interface SlotlockServeDashboardConfig {
|
|
25
30
|
githubClientId: string;
|
|
@@ -27,9 +32,116 @@ export interface SlotlockServeDashboardConfig {
|
|
|
27
32
|
sessionSecret: string;
|
|
28
33
|
/** GitHub user ids that may sign in, or `'*'` for every GitHub account. */
|
|
29
34
|
allowedUsers: '*' | string[];
|
|
35
|
+
/** GitHub organization ids whose members share a tenant each (SLOTLOCK_DASHBOARD_ORGS). */
|
|
36
|
+
organizations?: string[];
|
|
37
|
+
/** What the deployment is called in the dashboard (SLOTLOCK_DASHBOARD_NAME). */
|
|
38
|
+
name?: string;
|
|
30
39
|
}
|
|
31
40
|
/**
|
|
32
41
|
* The dashboard's settings, or undefined when none is set. Setting any of them means serving the
|
|
33
42
|
* dashboard, so the rest are then required: a half-configured sign-in is a startup error.
|
|
34
43
|
*/
|
|
35
44
|
export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDashboardConfig | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The GitHub organization ids of SLOTLOCK_DASHBOARD_ORGS, or undefined when unset. Read on its own as
|
|
47
|
+
* well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
|
|
48
|
+
* organizations' keys, and only theirs.
|
|
49
|
+
*/
|
|
50
|
+
export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | undefined;
|
|
51
|
+
/** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
|
|
52
|
+
export interface SlotlockRate {
|
|
53
|
+
perSecond: number;
|
|
54
|
+
burst: number;
|
|
55
|
+
}
|
|
56
|
+
/** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
|
|
57
|
+
export declare const SLOTLOCK_RATE_NAMES: readonly ["address", "key_read", "key_write", "tenant_read", "tenant_write", "global_read", "global_write", "app_read", "app_write", "sign_in"];
|
|
58
|
+
export type SlotlockRateName = (typeof SLOTLOCK_RATE_NAMES)[number];
|
|
59
|
+
/** The counts `slotlock serve` caps (SLOTLOCK_LIMITS), with the range each may be set to. */
|
|
60
|
+
declare const CAP_RANGES: Readonly<{
|
|
61
|
+
readonly tenant_series_writes: readonly [1, 100];
|
|
62
|
+
readonly series_writes: readonly [1, 100];
|
|
63
|
+
readonly maintenance_runs: readonly [1, 100];
|
|
64
|
+
readonly subscriptions: readonly [1, 10000];
|
|
65
|
+
readonly tenant_subscriptions: readonly [1, 10000];
|
|
66
|
+
readonly key_subscriptions: readonly [1, 64];
|
|
67
|
+
readonly key_lookups: readonly [1, 8];
|
|
68
|
+
readonly key_lookup_queue: readonly [0, 10000];
|
|
69
|
+
readonly tenant_transactions: readonly [1, 10];
|
|
70
|
+
readonly tenant_holds: readonly [1, 10000000];
|
|
71
|
+
readonly tenant_reservations: readonly [1, 10000000];
|
|
72
|
+
readonly tenant_events: readonly [1, 10000000];
|
|
73
|
+
readonly tenant_coverage: readonly [1, 10000000];
|
|
74
|
+
readonly holder_holds: readonly [1, 10000];
|
|
75
|
+
readonly holder_held_hours: readonly [1, 876000];
|
|
76
|
+
}>;
|
|
77
|
+
export type SlotlockCapName = keyof typeof CAP_RANGES;
|
|
78
|
+
export declare const SLOTLOCK_CAP_NAMES: readonly ("holder_held_hours" | "holder_holds" | "key_lookup_queue" | "key_lookups" | "key_subscriptions" | "maintenance_runs" | "series_writes" | "subscriptions" | "tenant_coverage" | "tenant_events" | "tenant_holds" | "tenant_reservations" | "tenant_series_writes" | "tenant_subscriptions" | "tenant_transactions")[];
|
|
79
|
+
export interface SlotlockLimits {
|
|
80
|
+
/** Each rate, or null where SLOTLOCK_LIMITS turns it off. */
|
|
81
|
+
rates: Readonly<Record<SlotlockRateName, SlotlockRate | null>>;
|
|
82
|
+
caps: Readonly<Record<SlotlockCapName, number>>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What `slotlock serve` allows unless SLOTLOCK_LIMITS says otherwise, sized for one public server on
|
|
86
|
+
* one CPU (the 2026-10-24 readiness audit measured the database at about 900 writes a second):
|
|
87
|
+
* - `address`: every request from one client address, before any credential is looked up.
|
|
88
|
+
* - `key_*`, `tenant_*`, `global_*`: an authenticated request takes a read token from its key, its
|
|
89
|
+
* tenant and the server; a write (a REST method other than GET or HEAD, or a write tool) takes a
|
|
90
|
+
* write token from each as well.
|
|
91
|
+
* - `app_*`: a request to the application API (application-api.ts) from an admin key takes its
|
|
92
|
+
* read and write tokens from its tenant's application buckets instead of the key and tenant ones,
|
|
93
|
+
* so the application and the tenant's agents cannot use up each other's budget; one from any
|
|
94
|
+
* other key takes them from that key's own. `global_*` counts it as well.
|
|
95
|
+
* - `sign_in`: dashboard sign-ins and their callbacks from one client network: an IPv4 address,
|
|
96
|
+
* or an IPv6 client's /48 (slotlockClientNetwork in rate-limit.ts), as the dashboard Worker
|
|
97
|
+
* counts them.
|
|
98
|
+
* - `tenant_series_writes` and `series_writes`: writes that may expand a recurring series (a
|
|
99
|
+
* recurring create, any update) running at once in a tenant and in each server process.
|
|
100
|
+
* - `maintenance_runs`: the application API's maintenance runs at once in each server process, one
|
|
101
|
+
* a tenant, in slots of their own. A freed slot of either kind rests for as long as the event
|
|
102
|
+
* loop was busy while its write held it, up to a second, then goes to the tenant with the fewest
|
|
103
|
+
* writes in flight and, among those, the least use lately. That keeps a tenant sending one write
|
|
104
|
+
* at a time from waiting behind tenants with more, not behind many tenants with one each
|
|
105
|
+
* (rate-limit.ts).
|
|
106
|
+
* - `subscriptions`: MCP live-update streams the server holds open. When it is full, a tenant
|
|
107
|
+
* holding fewer than the tenant holding most, less one, ends that tenant's oldest stream.
|
|
108
|
+
* - `tenant_subscriptions`: of those, how many one tenant may hold.
|
|
109
|
+
* - `key_subscriptions`: of those, how many one key (or the server token) may hold, and always
|
|
110
|
+
* fewer than its tenant's, so one key cannot lock the tenant's other keys out
|
|
111
|
+
* (slotlockKeySubscriptions).
|
|
112
|
+
* - `key_lookups` and `key_lookup_queue`: API key lookups running at once and waiting.
|
|
113
|
+
* - `tenant_transactions`: store transactions one tenant runs at once on the server's pool of 10
|
|
114
|
+
* connections; the rest wait their turn in the process, so a tenant's writes waiting on its own
|
|
115
|
+
* locks leave the other connections to other tenants and key lookups. A server with one tenant
|
|
116
|
+
* can set 10.
|
|
117
|
+
* - `tenant_holds`, `tenant_reservations`, `tenant_events` and `tenant_coverage`: what one tenant
|
|
118
|
+
* may store, counted as the store's tenant limits count it (SlotlockTenantLimits), whichever key,
|
|
119
|
+
* agent or application writes it. Every write surface of the server shares them, so no admin
|
|
120
|
+
* key can grow the shared database without end; a write past one answers 409 with its
|
|
121
|
+
* `*_limit_reached` code.
|
|
122
|
+
* - `holder_holds` and `holder_held_hours`: one holder's (an agent's) live holds, how many and how
|
|
123
|
+
* many hours they hold together (`hold_limit_reached`, `held_time_limit_reached`).
|
|
124
|
+
*/
|
|
125
|
+
export declare const SLOTLOCK_DEFAULT_LIMITS: SlotlockLimits;
|
|
126
|
+
/**
|
|
127
|
+
* The live-update streams one key may hold open: `key_subscriptions`, but at least one fewer than
|
|
128
|
+
* its tenant may (unless the tenant may hold only one), so a key at its own limit leaves its
|
|
129
|
+
* tenant's other keys a stream however the two caps are set.
|
|
130
|
+
*/
|
|
131
|
+
export declare function slotlockKeySubscriptions(caps: Readonly<Record<SlotlockCapName, number>>): number;
|
|
132
|
+
/** A rate as SLOTLOCK_LIMITS and the `listening` log line write it: `<count>/<s|m|h>:<burst>`. */
|
|
133
|
+
export declare function formatSlotlockRate(rate: SlotlockRate | null): string;
|
|
134
|
+
/**
|
|
135
|
+
* SLOTLOCK_LIMITS: comma-separated `name=value` overrides of SLOTLOCK_DEFAULT_LIMITS. A rate is
|
|
136
|
+
* `<count>/<s|m|h>`, optionally `:<burst>` (default: the count), or `off`; a cap is a whole number.
|
|
137
|
+
* For example `tenant_write=2/s:10,global_write=30/s,address=off`.
|
|
138
|
+
*/
|
|
139
|
+
export declare function readLimits(env: SlotlockEnv): SlotlockLimits;
|
|
140
|
+
/**
|
|
141
|
+
* SLOTLOCK_TRUSTED_PROXY_HEADER: the request field holding the client's address, set (and
|
|
142
|
+
* overwritten) by a proxy every request passes through, such as `CF-Connecting-IP` behind
|
|
143
|
+
* Cloudflare. Unset, the client address is the connection's. A client can send any field, so set it
|
|
144
|
+
* only when nothing reaches the server except through that proxy.
|
|
145
|
+
*/
|
|
146
|
+
export declare function readTrustedProxyHeader(env: SlotlockEnv): string | undefined;
|
|
147
|
+
export {};
|
package/config.js
CHANGED
|
@@ -92,6 +92,11 @@ export function readAvailability(env) {
|
|
|
92
92
|
};
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* A dashboard name (SLOTLOCK_DASHBOARD_NAME): 1-60 characters, none of them a control, format
|
|
97
|
+
* (bidi, zero-width) or line separator character, `<` or `>`.
|
|
98
|
+
*/
|
|
99
|
+
export const SLOTLOCK_DASHBOARD_NAME_PATTERN = /^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}<>]{1,60}$/u;
|
|
95
100
|
const DASHBOARD_VARIABLES = [
|
|
96
101
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
97
102
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
@@ -129,10 +134,230 @@ export function readDashboardConfig(env) {
|
|
|
129
134
|
if (users === undefined || (users !== '*' && !/^[1-9]\d{0,19}(,[1-9]\d{0,19})*$/.test(users))) {
|
|
130
135
|
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_USERS is required to serve the dashboard: * for every GitHub account, or a comma-separated list of GitHub user ids (gh api users/<login> --jq .id)');
|
|
131
136
|
}
|
|
137
|
+
const orgs = readDashboardOrganizations(env);
|
|
138
|
+
const name = optional(env, 'SLOTLOCK_DASHBOARD_NAME')?.trim();
|
|
139
|
+
if (name !== undefined && name !== '' && !SLOTLOCK_DASHBOARD_NAME_PATTERN.test(name)) {
|
|
140
|
+
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_NAME must be 1-60 characters, without <, > or control or invisible characters');
|
|
141
|
+
}
|
|
132
142
|
return {
|
|
133
143
|
githubClientId,
|
|
134
144
|
githubClientSecret,
|
|
135
145
|
sessionSecret,
|
|
136
146
|
allowedUsers: users === '*' ? '*' : [...new Set(users.split(','))],
|
|
147
|
+
...(orgs !== undefined ? { organizations: orgs } : {}),
|
|
148
|
+
...(name !== undefined && name !== '' ? { name } : {}),
|
|
137
149
|
};
|
|
138
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The GitHub organization ids of SLOTLOCK_DASHBOARD_ORGS, or undefined when unset. Read on its own as
|
|
153
|
+
* well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
|
|
154
|
+
* organizations' keys, and only theirs.
|
|
155
|
+
*/
|
|
156
|
+
export function readDashboardOrganizations(env) {
|
|
157
|
+
const orgs = optional(env, 'SLOTLOCK_DASHBOARD_ORGS')?.replaceAll(' ', '');
|
|
158
|
+
if (orgs === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (!/^[1-9]\d{0,19}(,[1-9]\d{0,19})*$/.test(orgs)) {
|
|
161
|
+
throw new SlotlockConfigError('SLOTLOCK_DASHBOARD_ORGS must be a comma-separated list of GitHub organization ids (gh api orgs/<login> --jq .id)');
|
|
162
|
+
}
|
|
163
|
+
return [...new Set(orgs.split(','))];
|
|
164
|
+
}
|
|
165
|
+
/** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
|
|
166
|
+
export const SLOTLOCK_RATE_NAMES = Object.freeze([
|
|
167
|
+
'address',
|
|
168
|
+
'key_read',
|
|
169
|
+
'key_write',
|
|
170
|
+
'tenant_read',
|
|
171
|
+
'tenant_write',
|
|
172
|
+
'global_read',
|
|
173
|
+
'global_write',
|
|
174
|
+
'app_read',
|
|
175
|
+
'app_write',
|
|
176
|
+
'sign_in',
|
|
177
|
+
]);
|
|
178
|
+
/** The counts `slotlock serve` caps (SLOTLOCK_LIMITS), with the range each may be set to. */
|
|
179
|
+
const CAP_RANGES = Object.freeze({
|
|
180
|
+
tenant_series_writes: [1, 100],
|
|
181
|
+
series_writes: [1, 100],
|
|
182
|
+
maintenance_runs: [1, 100],
|
|
183
|
+
subscriptions: [1, 10_000],
|
|
184
|
+
tenant_subscriptions: [1, 10_000],
|
|
185
|
+
key_subscriptions: [1, 64],
|
|
186
|
+
key_lookups: [1, 8],
|
|
187
|
+
key_lookup_queue: [0, 10_000],
|
|
188
|
+
// serve's pool has 10 connections: more than that bounds nothing.
|
|
189
|
+
tenant_transactions: [1, 10],
|
|
190
|
+
tenant_holds: [1, 10_000_000],
|
|
191
|
+
tenant_reservations: [1, 10_000_000],
|
|
192
|
+
tenant_events: [1, 10_000_000],
|
|
193
|
+
tenant_coverage: [1, 10_000_000],
|
|
194
|
+
holder_holds: [1, 10_000],
|
|
195
|
+
holder_held_hours: [1, 876_000],
|
|
196
|
+
});
|
|
197
|
+
export const SLOTLOCK_CAP_NAMES = Object.freeze(Object.keys(CAP_RANGES));
|
|
198
|
+
/**
|
|
199
|
+
* What `slotlock serve` allows unless SLOTLOCK_LIMITS says otherwise, sized for one public server on
|
|
200
|
+
* one CPU (the 2026-10-24 readiness audit measured the database at about 900 writes a second):
|
|
201
|
+
* - `address`: every request from one client address, before any credential is looked up.
|
|
202
|
+
* - `key_*`, `tenant_*`, `global_*`: an authenticated request takes a read token from its key, its
|
|
203
|
+
* tenant and the server; a write (a REST method other than GET or HEAD, or a write tool) takes a
|
|
204
|
+
* write token from each as well.
|
|
205
|
+
* - `app_*`: a request to the application API (application-api.ts) from an admin key takes its
|
|
206
|
+
* read and write tokens from its tenant's application buckets instead of the key and tenant ones,
|
|
207
|
+
* so the application and the tenant's agents cannot use up each other's budget; one from any
|
|
208
|
+
* other key takes them from that key's own. `global_*` counts it as well.
|
|
209
|
+
* - `sign_in`: dashboard sign-ins and their callbacks from one client network: an IPv4 address,
|
|
210
|
+
* or an IPv6 client's /48 (slotlockClientNetwork in rate-limit.ts), as the dashboard Worker
|
|
211
|
+
* counts them.
|
|
212
|
+
* - `tenant_series_writes` and `series_writes`: writes that may expand a recurring series (a
|
|
213
|
+
* recurring create, any update) running at once in a tenant and in each server process.
|
|
214
|
+
* - `maintenance_runs`: the application API's maintenance runs at once in each server process, one
|
|
215
|
+
* a tenant, in slots of their own. A freed slot of either kind rests for as long as the event
|
|
216
|
+
* loop was busy while its write held it, up to a second, then goes to the tenant with the fewest
|
|
217
|
+
* writes in flight and, among those, the least use lately. That keeps a tenant sending one write
|
|
218
|
+
* at a time from waiting behind tenants with more, not behind many tenants with one each
|
|
219
|
+
* (rate-limit.ts).
|
|
220
|
+
* - `subscriptions`: MCP live-update streams the server holds open. When it is full, a tenant
|
|
221
|
+
* holding fewer than the tenant holding most, less one, ends that tenant's oldest stream.
|
|
222
|
+
* - `tenant_subscriptions`: of those, how many one tenant may hold.
|
|
223
|
+
* - `key_subscriptions`: of those, how many one key (or the server token) may hold, and always
|
|
224
|
+
* fewer than its tenant's, so one key cannot lock the tenant's other keys out
|
|
225
|
+
* (slotlockKeySubscriptions).
|
|
226
|
+
* - `key_lookups` and `key_lookup_queue`: API key lookups running at once and waiting.
|
|
227
|
+
* - `tenant_transactions`: store transactions one tenant runs at once on the server's pool of 10
|
|
228
|
+
* connections; the rest wait their turn in the process, so a tenant's writes waiting on its own
|
|
229
|
+
* locks leave the other connections to other tenants and key lookups. A server with one tenant
|
|
230
|
+
* can set 10.
|
|
231
|
+
* - `tenant_holds`, `tenant_reservations`, `tenant_events` and `tenant_coverage`: what one tenant
|
|
232
|
+
* may store, counted as the store's tenant limits count it (SlotlockTenantLimits), whichever key,
|
|
233
|
+
* agent or application writes it. Every write surface of the server shares them, so no admin
|
|
234
|
+
* key can grow the shared database without end; a write past one answers 409 with its
|
|
235
|
+
* `*_limit_reached` code.
|
|
236
|
+
* - `holder_holds` and `holder_held_hours`: one holder's (an agent's) live holds, how many and how
|
|
237
|
+
* many hours they hold together (`hold_limit_reached`, `held_time_limit_reached`).
|
|
238
|
+
*/
|
|
239
|
+
export const SLOTLOCK_DEFAULT_LIMITS = Object.freeze({
|
|
240
|
+
rates: Object.freeze({
|
|
241
|
+
address: { perSecond: 50, burst: 100 },
|
|
242
|
+
key_read: { perSecond: 20, burst: 40 },
|
|
243
|
+
key_write: { perSecond: 5, burst: 20 },
|
|
244
|
+
tenant_read: { perSecond: 20, burst: 40 },
|
|
245
|
+
tenant_write: { perSecond: 5, burst: 20 },
|
|
246
|
+
global_read: { perSecond: 500, burst: 1_000 },
|
|
247
|
+
global_write: { perSecond: 50, burst: 100 },
|
|
248
|
+
app_read: { perSecond: 20, burst: 40 },
|
|
249
|
+
app_write: { perSecond: 5, burst: 20 },
|
|
250
|
+
sign_in: { perSecond: 20 / 60, burst: 20 },
|
|
251
|
+
}),
|
|
252
|
+
caps: Object.freeze({
|
|
253
|
+
tenant_series_writes: 1,
|
|
254
|
+
series_writes: 1,
|
|
255
|
+
maintenance_runs: 1,
|
|
256
|
+
subscriptions: 256,
|
|
257
|
+
tenant_subscriptions: 16,
|
|
258
|
+
key_subscriptions: 4,
|
|
259
|
+
key_lookups: 2,
|
|
260
|
+
key_lookup_queue: 256,
|
|
261
|
+
tenant_transactions: 2,
|
|
262
|
+
// Sized for the server's 10,000 resources a tenant: a live hold for every one, ten bookings and
|
|
263
|
+
// ten events a resource, and coverage from ten calendars a resource.
|
|
264
|
+
tenant_holds: 10_000,
|
|
265
|
+
tenant_reservations: 100_000,
|
|
266
|
+
tenant_events: 100_000,
|
|
267
|
+
tenant_coverage: 100_000,
|
|
268
|
+
// One agent's live holds: 20 at once (candidates for a few bookings), holding 90 days in all.
|
|
269
|
+
holder_holds: 20,
|
|
270
|
+
holder_held_hours: 2_160,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
/**
|
|
274
|
+
* The live-update streams one key may hold open: `key_subscriptions`, but at least one fewer than
|
|
275
|
+
* its tenant may (unless the tenant may hold only one), so a key at its own limit leaves its
|
|
276
|
+
* tenant's other keys a stream however the two caps are set.
|
|
277
|
+
*/
|
|
278
|
+
export function slotlockKeySubscriptions(caps) {
|
|
279
|
+
return Math.max(1, Math.min(caps.key_subscriptions, caps.tenant_subscriptions - 1));
|
|
280
|
+
}
|
|
281
|
+
const RATE_PERIOD_SECONDS = { s: 1, m: 60, h: 3_600 };
|
|
282
|
+
const MAX_RATE_COUNT = 1_000_000;
|
|
283
|
+
/** A rate as SLOTLOCK_LIMITS and the `listening` log line write it: `<count>/<s|m|h>:<burst>`. */
|
|
284
|
+
export function formatSlotlockRate(rate) {
|
|
285
|
+
if (rate === null)
|
|
286
|
+
return 'off';
|
|
287
|
+
// The smallest unit in which the count is whole: 50/s rather than 180000/h.
|
|
288
|
+
for (const [unit, seconds] of Object.entries(RATE_PERIOD_SECONDS)) {
|
|
289
|
+
const count = rate.perSecond * seconds;
|
|
290
|
+
if (Number.isInteger(Math.round(count * 1e9) / 1e9) && count >= 1) {
|
|
291
|
+
return `${Math.round(count)}/${unit}:${rate.burst}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return `${rate.perSecond}/s:${rate.burst}`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* SLOTLOCK_LIMITS: comma-separated `name=value` overrides of SLOTLOCK_DEFAULT_LIMITS. A rate is
|
|
298
|
+
* `<count>/<s|m|h>`, optionally `:<burst>` (default: the count), or `off`; a cap is a whole number.
|
|
299
|
+
* For example `tenant_write=2/s:10,global_write=30/s,address=off`.
|
|
300
|
+
*/
|
|
301
|
+
export function readLimits(env) {
|
|
302
|
+
const rates = { ...SLOTLOCK_DEFAULT_LIMITS.rates };
|
|
303
|
+
const caps = { ...SLOTLOCK_DEFAULT_LIMITS.caps };
|
|
304
|
+
const value = optional(env, 'SLOTLOCK_LIMITS');
|
|
305
|
+
if (value === undefined)
|
|
306
|
+
return { rates, caps };
|
|
307
|
+
const invalid = (detail) => new SlotlockConfigError(`SLOTLOCK_LIMITS must be comma-separated name=value settings, rates as <count>/<s|m|h>[:<burst>] or off (${SLOTLOCK_RATE_NAMES.join(', ')}) and caps as whole numbers (${SLOTLOCK_CAP_NAMES.join(', ')}): ${detail}`);
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
for (const entry of value.split(',').map((part) => part.trim())) {
|
|
310
|
+
const match = /^([a-z_]+)=(\S+)$/.exec(entry);
|
|
311
|
+
if (!match)
|
|
312
|
+
throw invalid(`"${entry.slice(0, 40)}" is not name=value`);
|
|
313
|
+
const [, name, setting] = match;
|
|
314
|
+
if (seen.has(name))
|
|
315
|
+
throw invalid(`${name} is set twice`);
|
|
316
|
+
seen.add(name);
|
|
317
|
+
if (SLOTLOCK_RATE_NAMES.includes(name)) {
|
|
318
|
+
if (setting === 'off') {
|
|
319
|
+
rates[name] = null;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const rate = /^(\d{1,7})\/([smh])(?::(\d{1,7}))?$/.exec(setting);
|
|
323
|
+
const count = Number(rate?.[1]);
|
|
324
|
+
const burst = rate?.[3] === undefined ? count : Number(rate[3]);
|
|
325
|
+
if (!rate || count < 1 || count > MAX_RATE_COUNT || burst < 1 || burst > MAX_RATE_COUNT) {
|
|
326
|
+
throw invalid(`${name} must be <count>/<s|m|h>[:<burst>] with each 1-${MAX_RATE_COUNT}, or off`);
|
|
327
|
+
}
|
|
328
|
+
rates[name] = {
|
|
329
|
+
perSecond: count / RATE_PERIOD_SECONDS[rate[2]],
|
|
330
|
+
burst,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
else if (Object.hasOwn(CAP_RANGES, name)) {
|
|
334
|
+
const [minimum, maximum] = CAP_RANGES[name];
|
|
335
|
+
const cap = /^\d{1,8}$/.test(setting) ? Number(setting) : Number.NaN;
|
|
336
|
+
if (!(cap >= minimum && cap <= maximum)) {
|
|
337
|
+
throw invalid(`${name} must be a whole number from ${minimum} to ${maximum}`);
|
|
338
|
+
}
|
|
339
|
+
caps[name] = cap;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
throw invalid(`${name.slice(0, 40)} is not a limit`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { rates, caps };
|
|
346
|
+
}
|
|
347
|
+
/** Fields a proxy cannot be trusted to set, because the client sends them to every server. */
|
|
348
|
+
const UNTRUSTABLE_FIELDS = new Set(['authorization', 'cookie', 'host', 'origin', 'content-type']);
|
|
349
|
+
/**
|
|
350
|
+
* SLOTLOCK_TRUSTED_PROXY_HEADER: the request field holding the client's address, set (and
|
|
351
|
+
* overwritten) by a proxy every request passes through, such as `CF-Connecting-IP` behind
|
|
352
|
+
* Cloudflare. Unset, the client address is the connection's. A client can send any field, so set it
|
|
353
|
+
* only when nothing reaches the server except through that proxy.
|
|
354
|
+
*/
|
|
355
|
+
export function readTrustedProxyHeader(env) {
|
|
356
|
+
const value = optional(env, 'SLOTLOCK_TRUSTED_PROXY_HEADER');
|
|
357
|
+
if (value === undefined)
|
|
358
|
+
return undefined;
|
|
359
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(value) || UNTRUSTABLE_FIELDS.has(value.toLowerCase())) {
|
|
360
|
+
throw new SlotlockConfigError('SLOTLOCK_TRUSTED_PROXY_HEADER must be the name of a request field your proxy sets to the client address, such as CF-Connecting-IP');
|
|
361
|
+
}
|
|
362
|
+
return value.toLowerCase();
|
|
363
|
+
}
|