slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/billing.js ADDED
@@ -0,0 +1,258 @@
1
+ // Hosted Slotlock's billing through Stripe: Checkout (with Managed Payments, Stripe as merchant of
2
+ // record) to start a Developer subscription, the customer portal to manage one, and the webhook that
3
+ // keeps a tenant's plan (plans.ts) in step with its subscription. Plain fetch, so it runs on a
4
+ // Worker. Self-hosted Slotlock never uses it.
5
+ import { createHmac, timingSafeEqual } from 'node:crypto';
6
+ import { readSlotlockTenantPlan, setSlotlockTenantPlan, } from './plans.js';
7
+ /**
8
+ * Stripe answered with an error, or did not answer, or (`subscription_exists`) the customer already
9
+ * pays for a subscription. The message never holds the key.
10
+ */
11
+ export class SlotlockBillingError extends Error {
12
+ status;
13
+ code;
14
+ constructor(message, status, code) {
15
+ super(message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.name = 'SlotlockBillingError';
19
+ }
20
+ }
21
+ /**
22
+ * The Stripe-Version every request sends unless `apiVersion` says otherwise: Managed Payments needs
23
+ * 2025-03-31.basil or later and became generally available in this one (docs.stripe.com changelog,
24
+ * read 2026-11-09). Pinned, so the account's default version never changes what is sent or read.
25
+ */
26
+ export const SLOTLOCK_STRIPE_API_VERSION = '2026-04-22.dahlia';
27
+ /** Statuses in which a subscription gives its tenant the Developer plan (slotlockPlanOfSubscription). */
28
+ const LIVE = new Set(['active', 'trialing', 'past_due']);
29
+ /** Whether the subscription gives its tenant a plan. */
30
+ export function slotlockSubscriptionIsLive(subscription) {
31
+ return LIVE.has(subscription.status);
32
+ }
33
+ const STRIPE_ID = /^[A-Za-z0-9_]{1,255}$/;
34
+ /** A tenant reference as Stripe's search may quote it: no quote, backslash or control character. */
35
+ const SEARCHABLE = /^[^'"\\\p{Cc}]{1,500}$/u;
36
+ export function createSlotlockStripe(options) {
37
+ const base = options.apiBase ?? 'https://api.stripe.com';
38
+ const send = options.fetch ?? fetch;
39
+ const timeoutMs = options.timeoutMs ?? 10_000;
40
+ async function call(method, path, body) {
41
+ const headers = {
42
+ authorization: `Bearer ${options.secretKey}`,
43
+ 'stripe-version': options.apiVersion ?? SLOTLOCK_STRIPE_API_VERSION,
44
+ };
45
+ if (body)
46
+ headers['content-type'] = 'application/x-www-form-urlencoded';
47
+ let response;
48
+ try {
49
+ response = await send(`${base}${path}`, {
50
+ method,
51
+ headers,
52
+ ...(body ? { body: body.toString() } : {}),
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ });
55
+ }
56
+ catch {
57
+ throw new SlotlockBillingError('Stripe did not answer');
58
+ }
59
+ const parsed = (await response.json().catch(() => null));
60
+ if (!response.ok || !parsed) {
61
+ const reason = [parsed?.error?.type, parsed?.error?.code].filter(Boolean).join(': ');
62
+ throw new SlotlockBillingError(`Stripe refused ${method} ${path.split('?')[0]} (${String(response.status)}${reason ? `, ${reason}` : ''})`, response.status);
63
+ }
64
+ return parsed;
65
+ }
66
+ async function liveSubscriptionsOfCustomer(customer) {
67
+ if (!STRIPE_ID.test(customer))
68
+ throw new SlotlockBillingError('Not a customer id');
69
+ // Without a status, Stripe lists every subscription that is not canceled.
70
+ const page = await call('GET', `/v1/subscriptions?${new URLSearchParams({ customer, limit: '100' }).toString()}`);
71
+ return (page.data ?? []).filter(slotlockSubscriptionIsLive);
72
+ }
73
+ return {
74
+ async checkout({ tenantRef, interval, plan = 'developer', customer, successUrl, cancelUrl }) {
75
+ if (plan === 'team' && !options.teamPrices)
76
+ throw new SlotlockBillingError('There is no Team price');
77
+ const price = plan === 'team' ? options.teamPrices[interval] : options.prices[interval];
78
+ // A customer who already pays changes the plan in the portal: a second Checkout would charge
79
+ // twice for one tenant. (A first purchase has no customer yet; the webhook catches that case.)
80
+ if (customer && (await liveSubscriptionsOfCustomer(customer)).length > 0) {
81
+ throw new SlotlockBillingError('The customer has a live subscription already', 409, 'subscription_exists');
82
+ }
83
+ const form = new URLSearchParams({
84
+ mode: 'subscription',
85
+ 'line_items[0][price]': price,
86
+ 'line_items[0][quantity]': '1',
87
+ // A metered price takes no quantity: what the meter reports is billed.
88
+ ...(plan === 'developer' && options.overagePrice ? { 'line_items[1][price]': options.overagePrice } : {}),
89
+ 'managed_payments[enabled]': 'true',
90
+ success_url: successUrl,
91
+ cancel_url: cancelUrl,
92
+ client_reference_id: tenantRef.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200),
93
+ 'metadata[tenant_ref]': tenantRef,
94
+ 'subscription_data[metadata][tenant_ref]': tenantRef,
95
+ });
96
+ if (customer)
97
+ form.set('customer', customer);
98
+ const session = await call('POST', '/v1/checkout/sessions', form);
99
+ if (!session.url)
100
+ throw new SlotlockBillingError('Stripe gave no Checkout URL');
101
+ return session.url;
102
+ },
103
+ async portal({ customer, returnUrl }) {
104
+ const session = await call('POST', '/v1/billing_portal/sessions', new URLSearchParams({ customer, return_url: returnUrl }));
105
+ if (!session.url)
106
+ throw new SlotlockBillingError('Stripe gave no portal URL');
107
+ return session.url;
108
+ },
109
+ async subscription(id) {
110
+ if (!STRIPE_ID.test(id))
111
+ throw new SlotlockBillingError('Not a subscription id');
112
+ return call('GET', `/v1/subscriptions/${id}`);
113
+ },
114
+ liveSubscriptionsOfCustomer,
115
+ planOf: (subscription) => slotlockPlanOfSubscription(subscription, options.teamPrices),
116
+ async liveSubscriptionsOfTenant(tenantRef) {
117
+ if (!SEARCHABLE.test(tenantRef))
118
+ throw new SlotlockBillingError('Not a searchable tenant');
119
+ const page = await call('GET', `/v1/subscriptions/search?${new URLSearchParams({
120
+ query: `metadata['tenant_ref']:'${tenantRef}'`,
121
+ limit: '100',
122
+ }).toString()}`);
123
+ return (page.data ?? []).filter((found) => slotlockSubscriptionIsLive(found) && found.metadata?.tenant_ref === tenantRef);
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Whether a webhook's body was signed by Stripe with this endpoint's secret
129
+ * (docs.stripe.com/webhooks#verify-manually): `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
130
+ * any v1 matching, within `toleranceSeconds` of now.
131
+ */
132
+ export function verifySlotlockStripeSignature(body, header, secret, nowMs, toleranceSeconds = 300) {
133
+ if (!header || !secret)
134
+ return false;
135
+ let timestamp;
136
+ const signatures = [];
137
+ for (const part of header.split(',')) {
138
+ const at = part.indexOf('=');
139
+ const key = part.slice(0, at).trim();
140
+ const value = part.slice(at + 1).trim();
141
+ if (key === 't' && /^\d{1,12}$/.test(value))
142
+ timestamp = Number(value);
143
+ if (key === 'v1' && /^[0-9a-f]{64}$/.test(value))
144
+ signatures.push(value);
145
+ }
146
+ if (timestamp === undefined || signatures.length === 0)
147
+ return false;
148
+ if (Math.abs(nowMs / 1000 - timestamp) > toleranceSeconds)
149
+ return false;
150
+ const expected = createHmac('sha256', secret).update(`${String(timestamp)}.${body}`).digest();
151
+ return signatures.some((signature) => timingSafeEqual(Buffer.from(signature, 'hex'), expected));
152
+ }
153
+ /** The plan a subscription gives its tenant. */
154
+ export function slotlockPlanOfSubscription(subscription, teamPrices) {
155
+ // Team when any item is at a Team price: changed to it in the portal, or bought as it.
156
+ const team = teamPrices !== undefined &&
157
+ (subscription.items?.data ?? []).some((item) => {
158
+ const id = typeof item.price === 'string' ? item.price : item.price?.id;
159
+ return id === teamPrices.month || id === teamPrices.year;
160
+ });
161
+ const paid = team ? 'team' : 'developer';
162
+ switch (subscription.status) {
163
+ case 'active':
164
+ case 'trialing':
165
+ return { plan: paid, status: 'active' };
166
+ case 'past_due':
167
+ // Stripe is retrying the payment: keep the plan while it does.
168
+ return { plan: paid, status: 'past_due' };
169
+ default:
170
+ // canceled, unpaid, incomplete, incomplete_expired, paused: the Free limits.
171
+ return { plan: 'free', status: 'canceled' };
172
+ }
173
+ }
174
+ /** The subscription a webhook event is about, if any. */
175
+ function subscriptionOf(event) {
176
+ const object = event?.data?.object;
177
+ if (!object)
178
+ return null;
179
+ if (object.object === 'subscription' && typeof object.id === 'string')
180
+ return object.id;
181
+ const field = object.subscription;
182
+ if (typeof field === 'string')
183
+ return field;
184
+ if (field && typeof field.id === 'string')
185
+ return field.id;
186
+ return null;
187
+ }
188
+ /**
189
+ * Handles one Stripe webhook delivery: checks its signature, then, for any event about a
190
+ * subscription, reads that subscription from Stripe (not the event, which may be stale or arrive out
191
+ * of order) and sets its tenant's plan. A tenant that bought twice (two tabs, before either webhook
192
+ * arrived) keeps Developer until its last live subscription ends, and the second purchase is
193
+ * reported through `onDuplicate`, by subscription ids only, for a refund. Answers 400 to an unsigned
194
+ * or unreadable body, 500 when Stripe or the database fails (Stripe retries), and 200 otherwise.
195
+ */
196
+ export async function handleSlotlockStripeWebhook(request, deps) {
197
+ const text = (status, body) => new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
198
+ if (request.method !== 'POST')
199
+ return text(405, 'Use POST.');
200
+ if (Number(request.headers.get('content-length') ?? 0) > 1_000_000)
201
+ return text(413, 'Too large.');
202
+ const body = await request.text();
203
+ if (body.length > 1_000_000)
204
+ return text(413, 'Too large.');
205
+ const signed = verifySlotlockStripeSignature(body, request.headers.get('stripe-signature'), deps.secret, (deps.now ?? Date.now)());
206
+ if (!signed)
207
+ return text(400, 'Bad signature.');
208
+ let event;
209
+ try {
210
+ event = JSON.parse(body);
211
+ }
212
+ catch {
213
+ return text(400, 'Not JSON.');
214
+ }
215
+ const id = subscriptionOf(event);
216
+ if (!id)
217
+ return text(200, 'Ignored.');
218
+ try {
219
+ const subscription = await deps.stripe.subscription(id);
220
+ const tenantRef = subscription.metadata?.tenant_ref;
221
+ // Not one of the dashboard's: nothing to set.
222
+ if (!tenantRef)
223
+ return text(200, 'Ignored.');
224
+ const current = await readSlotlockTenantPlan(deps.sql, tenantRef);
225
+ const named = current?.stripeSubscription ?? null;
226
+ let chosen = subscription;
227
+ if (!slotlockSubscriptionIsLive(subscription)) {
228
+ // An ended subscription only ends the plan it gave: not one the tenant has started since.
229
+ if (named && named !== subscription.id)
230
+ return text(200, 'Ignored.');
231
+ // Nor one the tenant still pays for under another subscription.
232
+ const other = (await deps.stripe.liveSubscriptionsOfTenant(tenantRef)).find((found) => found.id !== subscription.id);
233
+ if (other)
234
+ chosen = other;
235
+ }
236
+ else if (named && named !== subscription.id) {
237
+ const previous = await deps.stripe.subscription(named).catch(() => null);
238
+ if (previous && slotlockSubscriptionIsLive(previous))
239
+ deps.onDuplicate?.([previous.id, subscription.id]);
240
+ }
241
+ const { plan, status } = deps.stripe.planOf?.(chosen) ?? slotlockPlanOfSubscription(chosen);
242
+ const customer = typeof chosen.customer === 'string' ? chosen.customer : chosen.customer.id;
243
+ const end = chosen.items?.data?.[0]?.current_period_end ?? chosen.current_period_end ?? null;
244
+ await setSlotlockTenantPlan(deps.sql, {
245
+ tenantRef,
246
+ plan,
247
+ status,
248
+ stripeCustomer: customer,
249
+ stripeSubscription: plan === 'free' ? null : chosen.id,
250
+ currentPeriodEnd: plan === 'free' || !end ? null : new Date(end * 1000),
251
+ });
252
+ return text(200, 'Updated.');
253
+ }
254
+ catch (error) {
255
+ deps.onError?.(error);
256
+ return text(500, 'Try again.');
257
+ }
258
+ }
@@ -1,8 +1,7 @@
1
1
  import type { Interval, WeeklyAvailabilityRule } from './types.js';
2
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.
3
+ * A resource's booking rules: what a booking made through Slotlock (hold, reservation, agent event)
4
+ * must satisfy, and what a search offers. All optional. Imported calendar events are not checked.
6
5
  */
7
6
  export interface SlotlockBookingRules {
8
7
  /** A booking starts at least this many minutes after it is made: 0-525,600 (a year). */
@@ -15,35 +14,32 @@ export interface SlotlockBookingRules {
15
14
  */
16
15
  slotStepMinutes?: number;
17
16
  /**
18
- * Where the steps fall: this many minutes past each step from local midnight (with a 30-minute
19
- * step, 15 starts bookings on :15 and :45): 0 to one less than the step. Without a step it is
20
- * kept and does nothing.
17
+ * Offset of the steps from local midnight (with a 30-minute step, 15 starts bookings on :15 and
18
+ * :45): 0 to one less than the step. Ignored without a step.
21
19
  */
22
20
  stepOffsetMinutes?: number;
23
21
  /**
24
- * Minutes kept free before each booking made under the rule (preparation, travel), as its
25
- * leading buffer: the database refuses a booking whose time, or trailing buffer, reaches into
26
- * it. 0-1,440. A booking made before the rule, or imported, keeps none.
22
+ * Minutes kept free before each booking made under the rule, as its leading buffer: 0-1,440.
23
+ * Bookings made before the rule, or imported, keep none.
27
24
  */
28
25
  bufferBeforeMinutes?: number;
29
26
  /**
30
- * Minutes kept free after each booking made under the rule (cleaning, handover, travel), as its
31
- * trailing buffer, so the database refuses a booking that would start inside it: 0-1,440. A
32
- * booking made before the rule, or imported, keeps the buffer it has.
27
+ * Minutes kept free after each booking made under the rule, as its trailing buffer: 0-1,440.
28
+ * Bookings made before the rule, or imported, keep what they have.
33
29
  */
34
30
  turnaroundMinutes?: number;
35
31
  /** A booking lies inside the resource's bookable hours. */
36
32
  enforceHours?: boolean;
37
33
  /**
38
- * A booking is claimed (checked in) within this many minutes of its start, or maintenance
39
- * releases it for others (and offers it to the waitlist): 1-1,440. Only bookings made once the
40
- * rule is set are released.
34
+ * A booking must be checked in within this many minutes of its start, or maintenance releases it
35
+ * (and offers it to the waitlist): 1-1,440. Only bookings made after the rule is set are
36
+ * released.
41
37
  */
42
38
  checkInMinutes?: number;
43
39
  /**
44
- * A booking lies in time every calendar sync recording coverage for the resource has read within
45
- * the last day (`recordCalendarCoverage`): time a stale or failing sync leaves unproven is
46
- * refused (`uncertain`), not booked over what it may hide. A resource no sync covers is certain.
40
+ * A booking must lie in time every sync covering the resource read within the last day
41
+ * (`recordCalendarCoverage`): time a stale or failing sync leaves unproven is refused
42
+ * (`uncertain`). A resource no sync covers is certain.
47
43
  */
48
44
  requireCertain?: boolean;
49
45
  }
@@ -62,20 +58,18 @@ export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
62
58
  /** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
63
59
  export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
64
60
  /**
65
- * The rules that apply to a resource, merged from its own and its pool's and tenant's defaults
66
- * (each validated when it was set), read leniently: each field is kept when it is valid on its own
67
- * and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
68
- * to a resource's own shorter step, and the offset then applies modulo the step. A merge never
69
- * reads as refusing every booking.
61
+ * The effective rules merged from the resource's own, its pool's and the tenant's (each validated
62
+ * when set), read leniently: each field is kept if valid alone, else named in `dropped`. A pool's
63
+ * offset over a resource's shorter step applies modulo the step. A merge never refuses every
64
+ * booking.
70
65
  */
71
66
  export declare function parseEffectiveBookingRules(value: unknown): {
72
67
  rules: SlotlockBookingRules;
73
68
  dropped: string[];
74
69
  };
75
70
  /**
76
- * Default booking rules for a tenant or a pool from untrusted input: booking rules without
77
- * `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
78
- * was set, and that moment is the resource's).
71
+ * Tenant or pool default rules from untrusted input: no `checkInMinutes`, which only a resource's
72
+ * own rules set (it applies from the moment it was set on the resource).
79
73
  */
80
74
  export declare function parseBookingRuleDefaults(value: unknown): SlotlockBookingRules;
81
75
  /** The trailing buffer a booking made under the rules keeps: the larger of its own and the rule's. */
@@ -83,15 +77,14 @@ export declare function bookingBufferAfterMs(rules: SlotlockBookingRules | undef
83
77
  /** The leading buffer a booking made under the rules keeps. */
84
78
  export declare function bookingBufferBeforeMs(rules: SlotlockBookingRules | undefined): number;
85
79
  /**
86
- * The first instant at or after `from` that starts on a step in `timezone`, `offsetMinutes` past
87
- * each multiple of the step from local midnight (0 to the step less one). A step that does not
88
- * divide the day starts again at local midnight. Across a clock change it moves forward until the
89
- * local time is on a step.
80
+ * The first instant at or after `from` on a step in `timezone`, `offsetMinutes` past each multiple
81
+ * of the step from local midnight. A step that does not divide the day restarts at local midnight;
82
+ * across a clock change it moves forward to the next local step.
90
83
  */
91
84
  export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string, offsetMinutes?: number): Date;
92
85
  /**
93
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` are the resource's
94
- * bookable hours (its own, or the default), read only when the rules enforce them.
86
+ * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
87
+ * the default) are read only when enforced.
95
88
  */
96
89
  export declare function bookingRuleBroken(params: {
97
90
  rules: SlotlockBookingRules | undefined;
@@ -113,8 +106,7 @@ export interface SlotlockBookingSearch {
113
106
  }
114
107
  /**
115
108
  * How a search honours the rules: the part of `window` a booking made at `now` may start in, the
116
- * buffers each slot keeps, and how a candidate start moves to a step. `null` when no start in the
117
- * window is allowed.
109
+ * buffers each slot keeps, and how a candidate moves to a step. `null` when no start is allowed.
118
110
  */
119
111
  export declare function bookingSearch(params: {
120
112
  rules: SlotlockBookingRules | undefined;
@@ -123,14 +115,11 @@ export declare function bookingSearch(params: {
123
115
  now: Date;
124
116
  bufferAfterMs?: number;
125
117
  }): SlotlockBookingSearch | null;
126
- /**
127
- * The time a search must read as free or busy: its window, widened by the buffers a slot in it
128
- * keeps (before its first start and after its last end).
129
- */
118
+ /** The time a search must read as free or busy: its window widened by a slot's buffers. */
130
119
  export declare function bookingSearchReach(search: SlotlockBookingSearch): Interval;
131
120
  /**
132
- * Busy time as the slot times it rules out. A slot [s, s + d) keeping buffers occupies
133
- * [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
134
- * [b - after, e + before): the database refuses the same booking.
121
+ * Busy time as the slot times it rules out: a slot [s, s + d) with buffers occupies [s - before, s
122
+ * + d + after), so it meets busy [b, e) exactly when [s, s + d) meets [b - after, e + before), as
123
+ * the database decides.
135
124
  */
136
125
  export declare function bookingSearchBlocked(busy: readonly Interval[], search: SlotlockBookingSearch): Interval[];
package/booking-rules.js CHANGED
@@ -54,11 +54,10 @@ export function parseBookingRules(value) {
54
54
  return rules;
55
55
  }
56
56
  /**
57
- * The rules that apply to a resource, merged from its own and its pool's and tenant's defaults
58
- * (each validated when it was set), read leniently: each field is kept when it is valid on its own
59
- * and dropped (named in `dropped`) when not. No cross-field check: a merge may join a pool's offset
60
- * to a resource's own shorter step, and the offset then applies modulo the step. A merge never
61
- * reads as refusing every booking.
57
+ * The effective rules merged from the resource's own, its pool's and the tenant's (each validated
58
+ * when set), read leniently: each field is kept if valid alone, else named in `dropped`. A pool's
59
+ * offset over a resource's shorter step applies modulo the step. A merge never refuses every
60
+ * booking.
62
61
  */
63
62
  export function parseEffectiveBookingRules(value) {
64
63
  const rules = {};
@@ -88,9 +87,8 @@ export function parseEffectiveBookingRules(value) {
88
87
  return { rules, dropped };
89
88
  }
90
89
  /**
91
- * Default booking rules for a tenant or a pool from untrusted input: booking rules without
92
- * `checkInMinutes`, which only a resource's own rules set (it releases the bookings made after it
93
- * was set, and that moment is the resource's).
90
+ * Tenant or pool default rules from untrusted input: no `checkInMinutes`, which only a resource's
91
+ * own rules set (it applies from the moment it was set on the resource).
94
92
  */
95
93
  export function parseBookingRuleDefaults(value) {
96
94
  const rules = parseBookingRules(value);
@@ -123,10 +121,9 @@ function onStep(instant, stepMinutes, timezone, offset = 0) {
123
121
  return Number.isInteger(minutes) && minutes >= offset && (minutes - offset) % stepMinutes === 0;
124
122
  }
125
123
  /**
126
- * The first instant at or after `from` that starts on a step in `timezone`, `offsetMinutes` past
127
- * each multiple of the step from local midnight (0 to the step less one). A step that does not
128
- * divide the day starts again at local midnight. Across a clock change it moves forward until the
129
- * local time is on a step.
124
+ * The first instant at or after `from` on a step in `timezone`, `offsetMinutes` past each multiple
125
+ * of the step from local midnight. A step that does not divide the day restarts at local midnight;
126
+ * across a clock change it moves forward to the next local step.
130
127
  */
131
128
  export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
132
129
  const offset = offsetMinutes % stepMinutes;
@@ -142,8 +139,8 @@ export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
142
139
  return candidate;
143
140
  }
144
141
  /**
145
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` are the resource's
146
- * bookable hours (its own, or the default), read only when the rules enforce them.
142
+ * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
143
+ * the default) are read only when enforced.
147
144
  */
148
145
  export function bookingRuleBroken(params) {
149
146
  const { rules, interval, now } = params;
@@ -178,8 +175,7 @@ export function bookingRuleBroken(params) {
178
175
  }
179
176
  /**
180
177
  * How a search honours the rules: the part of `window` a booking made at `now` may start in, the
181
- * buffers each slot keeps, and how a candidate start moves to a step. `null` when no start in the
182
- * window is allowed.
178
+ * buffers each slot keeps, and how a candidate moves to a step. `null` when no start is allowed.
183
179
  */
184
180
  export function bookingSearch(params) {
185
181
  const { rules, window, now } = params;
@@ -195,7 +191,6 @@ export function bookingSearch(params) {
195
191
  const toStep = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
196
192
  const searched = { start: new Date(start), end: new Date(end) };
197
193
  if (rules?.maxAdvanceDays !== undefined) {
198
- // The latest allowed start bounds where a slot may start; it may still end after it.
199
194
  const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
200
195
  if (latestStart < start || end <= start)
201
196
  return null;
@@ -205,7 +200,7 @@ export function bookingSearch(params) {
205
200
  bufferBeforeMs,
206
201
  align: (from) => {
207
202
  const aligned = toStep ? toStep(from) : from;
208
- // Past the latest start, no slot: move it beyond the window so it cannot fit.
203
+ // Past the latest start: move it beyond the window so it cannot fit.
209
204
  return aligned.getTime() > latestStart ? new Date(end + 1) : aligned;
210
205
  },
211
206
  };
@@ -216,10 +211,7 @@ export function bookingSearch(params) {
216
211
  ? { window: searched, bufferAfterMs, bufferBeforeMs }
217
212
  : { window: searched, bufferAfterMs, bufferBeforeMs, align: toStep };
218
213
  }
219
- /**
220
- * The time a search must read as free or busy: its window, widened by the buffers a slot in it
221
- * keeps (before its first start and after its last end).
222
- */
214
+ /** The time a search must read as free or busy: its window widened by a slot's buffers. */
223
215
  export function bookingSearchReach(search) {
224
216
  return {
225
217
  start: new Date(search.window.start.getTime() - search.bufferBeforeMs),
@@ -227,9 +219,9 @@ export function bookingSearchReach(search) {
227
219
  };
228
220
  }
229
221
  /**
230
- * Busy time as the slot times it rules out. A slot [s, s + d) keeping buffers occupies
231
- * [s - before, s + d + after), so it meets busy [b, e) exactly when [s, s + d) meets
232
- * [b - after, e + before): the database refuses the same booking.
222
+ * Busy time as the slot times it rules out: a slot [s, s + d) with buffers occupies [s - before, s
223
+ * + d + after), so it meets busy [b, e) exactly when [s, s + d) meets [b - after, e + before), as
224
+ * the database decides.
233
225
  */
234
226
  export function bookingSearchBlocked(busy, search) {
235
227
  return busy.map((interval) => ({
package/change-watch.d.ts CHANGED
@@ -45,6 +45,7 @@ export interface SlotlockChangeWatcher {
45
45
  export declare const SLOTLOCK_FEED_BUSY_MS = 250;
46
46
  /** How often a quiet tenant's feed is read, in ms: the poll backs off to this from the busy rate. */
47
47
  export declare const SLOTLOCK_FEED_IDLE_MS = 1000;
48
+ export declare function unref<T>(timer: T): T;
48
49
  /**
49
50
  * The readers of one server process. `join` adds a watcher to its tenant's reader (starting it) and
50
51
  * returns the function that removes it (stopping the reader with its last watcher).
package/change-watch.js CHANGED
@@ -17,7 +17,7 @@ export const SLOTLOCK_FEED_BUSY_MS = 250;
17
17
  export const SLOTLOCK_FEED_IDLE_MS = 1_000;
18
18
  /** How often a quiet feed is read while a custom source's `watch` is listening, in ms. */
19
19
  const FEED_LISTENING_IDLE_MS = 15_000;
20
- function unref(timer) {
20
+ export function unref(timer) {
21
21
  timer.unref?.();
22
22
  return timer;
23
23
  }
package/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // The `slotlock` executable: process wiring only (arguments, environment, output streams, signals
3
- // and the exit code). The commands and their configuration live in self-host.ts.
2
+ // The `slotlock` executable: process wiring only; the commands live in self-host.ts.
4
3
  import { runSlotlockCli } from './self-host.js';
5
4
  const shutdown = new AbortController();
6
5
  for (const signal of ['SIGINT', 'SIGTERM']) {
@@ -17,6 +16,6 @@ process.exitCode = await runSlotlockCli(process.argv.slice(2), {
17
16
  stderr: process.stderr,
18
17
  signal: shutdown.signal,
19
18
  });
20
- // Everything is closed by now, so the process ends on its own. Should a stray handle (a socket or
21
- // timer something else left open) keep it alive, exit anyway rather than hang a container stop.
19
+ // Everything is closed, so the process should end on its own; exit anyway if a stray handle would
20
+ // hang a container stop.
22
21
  setTimeout(() => process.exit(), 5_000).unref();
package/config.d.ts CHANGED
@@ -30,17 +30,38 @@ export interface SlotlockServeDashboardConfig {
30
30
  githubClientId: string;
31
31
  githubClientSecret: string;
32
32
  sessionSecret: string;
33
- /** GitHub user ids that may sign in, or `'*'` for every GitHub account. */
33
+ /** GitHub user ids and Google accounts (`google:<subject>`) that may sign in, or `'*'` for everyone. */
34
34
  allowedUsers: '*' | string[];
35
- /** GitHub organization ids whose members share a tenant each (SLOTLOCK_DASHBOARD_ORGS). */
36
- organizations?: string[];
35
+ /** Sign-in with Google too (SLOTLOCK_GOOGLE_CLIENT_ID and SLOTLOCK_GOOGLE_CLIENT_SECRET). */
36
+ google?: {
37
+ clientId: string;
38
+ clientSecret: string;
39
+ };
40
+ /**
41
+ * GitHub organization ids whose members share a tenant each (SLOTLOCK_DASHBOARD_ORGS), or
42
+ * `'enabled'`: those an owner turned on from the dashboard (Teams on Cloud, teams.ts).
43
+ */
44
+ organizations?: string[] | 'enabled';
37
45
  /** What the deployment is called in the dashboard (SLOTLOCK_DASHBOARD_NAME). */
38
46
  name?: string;
47
+ /**
48
+ * The terms people accept by signing in (SLOTLOCK_DASHBOARD_TERMS_URL): the sign-in page links
49
+ * them beside the button. Without it, the page names no terms.
50
+ */
51
+ termsUrl?: string;
52
+ /** What the sign-in page links as the privacy notice (SLOTLOCK_DASHBOARD_PRIVACY_URL). */
53
+ privacyUrl?: string;
39
54
  /**
40
55
  * Seals each tenant's model API key for the dashboard assistant (SLOTLOCK_ASSISTANT_SECRET).
41
56
  * Without it the assistant is off.
42
57
  */
43
58
  assistantSecret?: string;
59
+ /**
60
+ * Secrets model keys may still be sealed under, newest first (SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS,
61
+ * comma-separated): each key one opens is sealed again under SLOTLOCK_ASSISTANT_SECRET when next
62
+ * used.
63
+ */
64
+ assistantPreviousSecrets?: string[];
44
65
  /**
45
66
  * Let the assistant reach a model at a local or private network address
46
67
  * (SLOTLOCK_ASSISTANT_PRIVATE_URLS=on): a self-hosted model on the same machine or network.
@@ -48,8 +69,14 @@ export interface SlotlockServeDashboardConfig {
48
69
  assistantPrivateUrls?: boolean;
49
70
  }
50
71
  /**
51
- * The dashboard's settings, or undefined when none is set. Setting any of them means serving the
52
- * dashboard, so the rest are then required: a half-configured sign-in is a startup error.
72
+ * SLOTLOCK_DASHBOARD_USERS: `'*'`, GitHub user ids, or undefined when it is not set. A server whose
73
+ * dashboard runs elsewhere (a Worker) reads it too, so that removing a person there stops their keys.
74
+ */
75
+ export declare function readDashboardUsers(env: SlotlockEnv): '*' | string[] | undefined;
76
+ /**
77
+ * The dashboard's settings, or undefined when none of DASHBOARD_VARIABLES is set. Setting any of
78
+ * them means serving the dashboard, so the rest, and SLOTLOCK_DASHBOARD_USERS, are then required: a
79
+ * half-configured sign-in is a startup error.
53
80
  */
54
81
  export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDashboardConfig | undefined;
55
82
  /**
@@ -57,7 +84,7 @@ export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDash
57
84
  * well: a server whose dashboard runs elsewhere (on Cloudflare Workers) still takes those
58
85
  * organizations' keys, and only theirs.
59
86
  */
60
- export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | undefined;
87
+ export declare function readDashboardOrganizations(env: SlotlockEnv): string[] | 'enabled' | undefined;
61
88
  /** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
62
89
  export interface SlotlockRate {
63
90
  perSecond: number;
@@ -154,4 +181,13 @@ export declare function readLimits(env: SlotlockEnv): SlotlockLimits;
154
181
  * only when nothing reaches the server except through that proxy.
155
182
  */
156
183
  export declare function readTrustedProxyHeader(env: SlotlockEnv): string | undefined;
184
+ /** The request field that carries SLOTLOCK_TRUSTED_PROXY_SECRET; not cf-…, which Cloudflare's rules may not set. */
185
+ export declare const SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD = "x-slotlock-proxy-proof";
186
+ /**
187
+ * SLOTLOCK_TRUSTED_PROXY_SECRET: a value the proxy in front adds to every request in
188
+ * SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, such as a Cloudflare transform rule. With it, the trusted
189
+ * proxy field is believed only beside that value, so a process that reaches the server without
190
+ * passing the proxy (another container on its network) cannot choose its own client address.
191
+ */
192
+ export declare function readTrustedProxySecret(env: SlotlockEnv, trustedProxyHeader: string | undefined): string | undefined;
157
193
  export {};