slotlock 0.3.0 → 0.5.1

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.
@@ -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,12 +32,22 @@ 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;
36
51
  /** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
37
52
  export interface SlotlockRate {
38
53
  perSecond: number;
@@ -56,9 +71,11 @@ declare const CAP_RANGES: Readonly<{
56
71
  readonly tenant_reservations: readonly [1, 10000000];
57
72
  readonly tenant_events: readonly [1, 10000000];
58
73
  readonly tenant_coverage: readonly [1, 10000000];
74
+ readonly holder_holds: readonly [1, 10000];
75
+ readonly holder_held_hours: readonly [1, 876000];
59
76
  }>;
60
77
  export type SlotlockCapName = keyof typeof CAP_RANGES;
61
- export declare const SLOTLOCK_CAP_NAMES: readonly ("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")[];
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")[];
62
79
  export interface SlotlockLimits {
63
80
  /** Each rate, or null where SLOTLOCK_LIMITS turns it off. */
64
81
  rates: Readonly<Record<SlotlockRateName, SlotlockRate | null>>;
@@ -102,6 +119,8 @@ export interface SlotlockLimits {
102
119
  * agent or application writes it. Every write surface of the server shares them, so no admin
103
120
  * key can grow the shared database without end; a write past one answers 409 with its
104
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`).
105
124
  */
106
125
  export declare const SLOTLOCK_DEFAULT_LIMITS: SlotlockLimits;
107
126
  /**
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,13 +134,34 @@ 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
+ }
139
165
  /** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
140
166
  export const SLOTLOCK_RATE_NAMES = Object.freeze([
141
167
  'address',
@@ -165,6 +191,8 @@ const CAP_RANGES = Object.freeze({
165
191
  tenant_reservations: [1, 10_000_000],
166
192
  tenant_events: [1, 10_000_000],
167
193
  tenant_coverage: [1, 10_000_000],
194
+ holder_holds: [1, 10_000],
195
+ holder_held_hours: [1, 876_000],
168
196
  });
169
197
  export const SLOTLOCK_CAP_NAMES = Object.freeze(Object.keys(CAP_RANGES));
170
198
  /**
@@ -205,6 +233,8 @@ export const SLOTLOCK_CAP_NAMES = Object.freeze(Object.keys(CAP_RANGES));
205
233
  * agent or application writes it. Every write surface of the server shares them, so no admin
206
234
  * key can grow the shared database without end; a write past one answers 409 with its
207
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`).
208
238
  */
209
239
  export const SLOTLOCK_DEFAULT_LIMITS = Object.freeze({
210
240
  rates: Object.freeze({
@@ -235,6 +265,9 @@ export const SLOTLOCK_DEFAULT_LIMITS = Object.freeze({
235
265
  tenant_reservations: 100_000,
236
266
  tenant_events: 100_000,
237
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,
238
271
  }),
239
272
  });
240
273
  /**
package/dashboard.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import type { SlotlockApiKeyStore } from './api-keys.js';
2
- import type { SlotlockSql, SlotlockStore } from './store.js';
3
- import type { SlotlockResource, WeeklyAvailabilityRule } from './types.js';
2
+ import { type SlotlockBookingRules } from './booking-rules.js';
3
+ import { type CreateWebhookEndpointResult, type SlotlockChange, type SlotlockWebhookEndpoint } from './event-feed.js';
4
+ import type { SlotlockSql, SlotlockStore, SlotlockWaitlistEntry } from './store.js';
5
+ import type { Interval, SlotlockAgendaEntry, SlotlockResource, WeeklyAvailabilityRule } from './types.js';
4
6
  /** Resources one dashboard tenant may hold. */
5
7
  export declare const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
6
- /** A tenant's calendar resources, as the dashboard lists and adds them. */
8
+ /**
9
+ * A tenant's calendar resources, as the dashboard lists and adds them. Each write takes, last, the
10
+ * person who made it (`github:<user id>`) as `actor`, to record on the event feed.
11
+ */
7
12
  export interface SlotlockDashboardResources {
8
13
  list(tenantRef: string): Promise<SlotlockResource[]>;
9
14
  /**
@@ -11,7 +16,7 @@ export interface SlotlockDashboardResources {
11
16
  * (an error with `code: 'resource_limit_reached'`) once the tenant has
12
17
  * SLOTLOCK_DASHBOARD_MAX_RESOURCES, however many adds race, on however many servers.
13
18
  */
14
- add(tenantRef: string, externalRef: string, timezone: string): Promise<SlotlockResource>;
19
+ add(tenantRef: string, externalRef: string, timezone: string, actor?: string): Promise<SlotlockResource>;
15
20
  /**
16
21
  * Set the bookable hours of one of the tenant's resources (`{ id }`) or of all of them; `null`
17
22
  * hands them back to the server's and `[]` closes. Returns how many resources changed: 0 for an
@@ -19,7 +24,32 @@ export interface SlotlockDashboardResources {
19
24
  */
20
25
  setHours(tenantRef: string, target: {
21
26
  id: string;
22
- } | 'all', rules: WeeklyAvailabilityRule[] | null): Promise<number>;
27
+ } | 'all', rules: WeeklyAvailabilityRule[] | null, actor?: string): Promise<number>;
28
+ /** What occupies the tenant's resources in `window`, by start: the overview's agenda. */
29
+ agenda?(tenantRef: string, window: Interval, limit: number): Promise<SlotlockAgendaEntry[]>;
30
+ /** The tenant's latest changes, newest first: the overview's activity. */
31
+ recentChanges?(tenantRef: string, limit: number): Promise<SlotlockChange[]>;
32
+ /**
33
+ * Set the booking rules of one of the tenant's resources (null clears them): the resource, or
34
+ * null when it is not the tenant's. Invalid rules throw with `code: 'invalid_booking_rules'`.
35
+ */
36
+ setRules?(tenantRef: string, id: string, rules: SlotlockBookingRules | null, actor?: string): Promise<SlotlockResource | null>;
37
+ /** Put one of the tenant's resources in a pool, or none: as setRules; `code: 'invalid_pool'`. */
38
+ setPool?(tenantRef: string, id: string, pool: string | null, actor?: string): Promise<SlotlockResource | null>;
39
+ /** The tenant's waitlist, oldest first, and leaving it (false when the entry is not the tenant's). */
40
+ waitlist?: {
41
+ list(tenantRef: string, limit: number): Promise<SlotlockWaitlistEntry[]>;
42
+ leave(tenantRef: string, id: string, actor?: string): Promise<boolean>;
43
+ };
44
+ /** The tenant's webhook endpoints, as the event feed keeps them. */
45
+ webhooks?: {
46
+ list(tenantRef: string): Promise<SlotlockWebhookEndpoint[]>;
47
+ create(tenantRef: string, url: string, eventTypes: readonly string[] | undefined, actor?: string): Promise<CreateWebhookEndpointResult>;
48
+ remove(tenantRef: string, id: string, actor?: string): Promise<boolean>;
49
+ enable(tenantRef: string, id: string, actor?: string): Promise<SlotlockWebhookEndpoint | null | {
50
+ code: 'changes_pruned';
51
+ }>;
52
+ };
23
53
  }
24
54
  /**
25
55
  * What the dashboard records so a sign-in callback works once, a sent form runs once and a
@@ -47,6 +77,8 @@ export interface SlotlockDashboardState {
47
77
  export interface SlotlockDashboardOptions {
48
78
  /** Where people reach the server; the dashboard lives at `<publicUrl>/dashboard`. */
49
79
  publicUrl: string;
80
+ /** What the deployment is called in the dashboard: 1-60 characters. Defaults to `Slotlock`. */
81
+ name?: string;
50
82
  /**
51
83
  * Where agents reach the server's MCP and A2A endpoints, which the dashboard shows, when it is
52
84
  * served from another origin (on Cloudflare Workers, with the server elsewhere). Defaults to
@@ -63,11 +95,23 @@ export interface SlotlockDashboardOptions {
63
95
  authorizeUrl?: string;
64
96
  tokenUrl?: string;
65
97
  userUrl?: string;
98
+ membershipsUrl?: string;
66
99
  };
67
100
  /** Seals sessions and sign-in state and derives CSRF tokens: 32+ random characters. */
68
101
  sessionSecret: string;
69
102
  /** GitHub user ids (numbers, as strings) that may sign in, or `'*'` for every GitHub account. */
70
103
  allowedUsers: '*' | readonly string[];
104
+ /**
105
+ * GitHub organization ids (numbers, as strings) this dashboard serves. With them, a tenant is an
106
+ * organization (`github-org:<id>`), not a person: sign-in asks for `read:org`, and a person signs
107
+ * in to the served organizations they are an active member of, sharing each one's keys and
108
+ * resources with its other members: owners and members, not billing managers, from the first
109
+ * 100 memberships GitHub lists, up to 20 served organizations. Membership is read at each sign-in; leaving an organization
110
+ * ends access at the next sign-in, at most a session (12 hours) later, and removing an
111
+ * organization from this list signs its sessions out at once. Without it, each person is their
112
+ * own tenant (`github:<id>`).
113
+ */
114
+ organizations?: readonly string[];
71
115
  keys: SlotlockApiKeyStore;
72
116
  resources: SlotlockDashboardResources;
73
117
  /** Shared by every server behind this origin: see SlotlockDashboardState. */
@@ -98,6 +142,18 @@ export interface SlotlockDashboard {
98
142
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
99
143
  */
100
144
  export declare function createSlotlockDashboardResources(store: SlotlockStore): SlotlockDashboardResources;
145
+ /**
146
+ * Records accepted write confirmations (`SlotlockAgentConfirmationOptions.consumeAcceptance`) in
147
+ * Postgres, shared by every server on the database: true the first time a confirmation's nonce is
148
+ * recorded, false while it stays recorded, so an accepted confirmation runs its write once on any
149
+ * server. Kept until the confirmation's own expiry, through the same function and table as the
150
+ * dashboard's records (only a SHA-256 digest of the nonce is stored); pass a client, not a
151
+ * transaction.
152
+ */
153
+ export declare function createSlotlockAcceptanceRecords(sql: SlotlockSql): (acceptance: {
154
+ nonce: string;
155
+ expiresAt: Date;
156
+ }) => Promise<boolean>;
101
157
  /**
102
158
  * The dashboard's records in Postgres, shared by every server on the database. Run `applySchema()`
103
159
  * and `grantApplicationRole()` first: the serving role reaches the records only through