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.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. 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;
@@ -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
+ }