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.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- 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
|
+
}
|
package/booking-rules.d.ts
CHANGED
|
@@ -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 (
|
|
4
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
25
|
-
*
|
|
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
|
|
31
|
-
*
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
*
|
|
77
|
-
*
|
|
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`
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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`
|
|
94
|
-
*
|
|
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
|
|
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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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`
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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`
|
|
146
|
-
*
|
|
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
|
|
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
|
|
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
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
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
|
|
21
|
-
//
|
|
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
|
|
33
|
+
/** GitHub user ids and Google accounts (`google:<subject>`) that may sign in, or `'*'` for everyone. */
|
|
34
34
|
allowedUsers: '*' | string[];
|
|
35
|
-
/**
|
|
36
|
-
|
|
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
|
-
*
|
|
52
|
-
* dashboard
|
|
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 {};
|