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/assistant.js
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
// The dashboard assistant: an organization connects
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// approves on a form before it runs. Nothing the model says changes the list of tools.
|
|
1
|
+
// The dashboard assistant: an organization connects its own model (the Anthropic Messages API, or
|
|
2
|
+
// any OpenAI-compatible chat completions endpoint) and its members operate Slotlock by chat. The
|
|
3
|
+
// server runs the tool loop with a fixed tool list: reads go through the agent operation boundary
|
|
4
|
+
// (schemas, scopes, limits), and every write becomes an action the member approves before it runs.
|
|
5
|
+
// Nothing the model says changes the list of tools.
|
|
7
6
|
//
|
|
8
|
-
// Request formats
|
|
9
|
-
// and
|
|
10
|
-
// JSON-string arguments, role "tool" results), as the vendors' official TypeScript SDKs describe
|
|
11
|
-
// them: @anthropic-ai/sdk 1.3.0 and openai 7.45.1, read from the npm registry 2026-10-28.
|
|
7
|
+
// Request formats as the vendors' official TypeScript SDKs describe them: @anthropic-ai/sdk 1.3.0
|
|
8
|
+
// and openai 7.45.1 (read from the npm registry 2026-10-28).
|
|
12
9
|
//
|
|
13
|
-
// The API key is sealed with AES-256-GCM (WebCrypto
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// The API key is sealed with AES-256-GCM (WebCrypto) under a key HKDF-SHA-256-derived from
|
|
11
|
+
// SLOTLOCK_ASSISTANT_SECRET, with the tenant as additional data: a sealed key copied to another
|
|
12
|
+
// tenant's row does not open.
|
|
16
13
|
import { slotlockAgentOperationScope, slotlockAgentTools, } from './agent-server.js';
|
|
17
14
|
import { isIP } from 'node:net';
|
|
18
15
|
import { isPrivateAddress } from './webhooks.js';
|
|
19
16
|
/**
|
|
20
|
-
* The tools the model is offered, by agent operation. A record over every operation, so
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* offer them.
|
|
17
|
+
* The tools the model is offered, by agent operation. A record over every operation, so a new one
|
|
18
|
+
* does not compile until placed here. Reads run when called; writes are only proposed and run on
|
|
19
|
+
* approval. API keys, webhooks, booking rules, pools, erasure and coverage are application
|
|
20
|
+
* operations, never agent ones, so none can be offered.
|
|
25
21
|
*/
|
|
26
22
|
export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
|
|
27
23
|
slotlock_list_resources: 'read',
|
|
@@ -38,6 +34,9 @@ export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
|
|
|
38
34
|
slotlock_create_event: 'propose',
|
|
39
35
|
slotlock_update_event: 'propose',
|
|
40
36
|
slotlock_delete_event: 'propose',
|
|
37
|
+
slotlock_confirm_slot: 'propose',
|
|
38
|
+
slotlock_reschedule: 'propose',
|
|
39
|
+
slotlock_cancel: 'propose',
|
|
41
40
|
});
|
|
42
41
|
for (const [operation, use] of Object.entries(SLOTLOCK_ASSISTANT_TOOL_USE)) {
|
|
43
42
|
// A read the assistant runs freely must be one the agent server calls a read.
|
|
@@ -52,15 +51,16 @@ export const SLOTLOCK_ASSISTANT_MAX_MESSAGE = 2_000;
|
|
|
52
51
|
/** Entries a conversation keeps; the oldest exchanges go first. */
|
|
53
52
|
const MAX_ENTRIES = 60;
|
|
54
53
|
/**
|
|
55
|
-
* UTF-8 bytes of the stored conversation as JSON
|
|
56
|
-
*
|
|
57
|
-
* table's 262,144-byte CHECK on `messages::text` whatever the text.
|
|
54
|
+
* UTF-8 bytes of the stored conversation as JSON. jsonb text adds at most one space per `:` or `,`,
|
|
55
|
+
* so this stays under the table's 262,144-byte CHECK on `messages::text`.
|
|
58
56
|
*/
|
|
59
57
|
export const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160_000;
|
|
60
58
|
/** UTF-8 bytes of the kept actions as JSON: under the 131,072-byte CHECK on `actions::text` as above. */
|
|
61
59
|
const MAX_ACTIONS_BYTES = 80_000;
|
|
62
60
|
/** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
|
|
63
61
|
export const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16_000;
|
|
62
|
+
/** Previous secrets a deployment may keep while keys are sealed again under its current one. */
|
|
63
|
+
export const SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS = 3;
|
|
64
64
|
/** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
|
|
65
65
|
export const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
|
|
66
66
|
/** Characters of one tool result the model is shown. */
|
|
@@ -72,15 +72,10 @@ const MAX_CALLS_PER_STEP = 8;
|
|
|
72
72
|
/** Bytes of one provider answer. */
|
|
73
73
|
const MAX_RESPONSE_BYTES = 1_048_576;
|
|
74
74
|
const MAX_OUTPUT_TOKENS = 1_024;
|
|
75
|
-
/**
|
|
76
|
-
* The Messages API version this sends: the one @anthropic-ai/sdk 1.3.0 (2026-10-23) sends, read
|
|
77
|
-
* from its source on 2026-10-28. Top-level `system`, `tools[].input_schema`, `tool_use` and
|
|
78
|
-
* `tool_result` blocks are as before in it.
|
|
79
|
-
*/
|
|
75
|
+
/** The Messages API version @anthropic-ai/sdk 1.3.0 sends (read from its source 2026-10-28). */
|
|
80
76
|
export const SLOTLOCK_ANTHROPIC_VERSION = '2026-10-21';
|
|
81
77
|
/** The assistant's records in the store, each in a transaction under the tenant's row-level security. */
|
|
82
78
|
export function createSlotlockAssistantStore(store) {
|
|
83
|
-
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
84
79
|
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
85
80
|
return {
|
|
86
81
|
getSettings: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAssistantSettings({ tenantRef })),
|
|
@@ -90,8 +85,9 @@ export function createSlotlockAssistantStore(store) {
|
|
|
90
85
|
...(actor !== undefined ? { updatedBy: actor } : {}),
|
|
91
86
|
}), by(actor)),
|
|
92
87
|
deleteSettings: (tenantRef, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAssistantSettings({ tenantRef }), by(actor)),
|
|
93
|
-
|
|
94
|
-
//
|
|
88
|
+
resealKey: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.resealAssistantKey({ tenantRef, from, to }), by(undefined)),
|
|
89
|
+
// Each read deletes the tenant's expired conversations first, for deployments whose maintenance
|
|
90
|
+
// never meets the tenant (a Worker's).
|
|
95
91
|
getConversation: (tenantRef, memberRef) => store.withTenant(tenantRef, async (tenant) => {
|
|
96
92
|
await tenant.pruneAssistantConversations({ tenantRef, limit: 100 });
|
|
97
93
|
return tenant.getAssistantConversation({ tenantRef, memberRef });
|
|
@@ -143,6 +139,13 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
143
139
|
async deleteSettings(tenantRef) {
|
|
144
140
|
return settings.delete(tenantRef);
|
|
145
141
|
},
|
|
142
|
+
async resealKey(tenantRef, from, to) {
|
|
143
|
+
const stored = settings.get(tenantRef);
|
|
144
|
+
if (!stored || stored.sealedKey !== from || to.length === 0 || to === from)
|
|
145
|
+
return false;
|
|
146
|
+
settings.set(tenantRef, { ...stored, sealedKey: to });
|
|
147
|
+
return true;
|
|
148
|
+
},
|
|
146
149
|
async getConversation(tenantRef, memberRef) {
|
|
147
150
|
const found = live(tenantRef, memberRef);
|
|
148
151
|
if (!found)
|
|
@@ -156,7 +159,7 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
156
159
|
},
|
|
157
160
|
async putConversation(tenantRef, memberRef, conversation, ttlMs) {
|
|
158
161
|
const stored = live(tenantRef, memberRef);
|
|
159
|
-
// As the store:
|
|
162
|
+
// As the store does: stored ones kept as they stand, then new ones not stored already.
|
|
160
163
|
const actions = {};
|
|
161
164
|
for (const id of conversation.keep) {
|
|
162
165
|
const kept = stored?.actions[id];
|
|
@@ -188,7 +191,6 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
188
191
|
},
|
|
189
192
|
};
|
|
190
193
|
}
|
|
191
|
-
// ---- The key, sealed ----
|
|
192
194
|
const SEAL_VERSION = 'v1';
|
|
193
195
|
const encoder = new TextEncoder();
|
|
194
196
|
async function sealingKey(secret) {
|
|
@@ -209,6 +211,19 @@ export async function sealSlotlockAssistantKey(secret, tenantRef, apiKey) {
|
|
|
209
211
|
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: boundTo(tenantRef) }, await sealingKey(secret), encoder.encode(apiKey));
|
|
210
212
|
return `${SEAL_VERSION}.${Buffer.from(iv).toString('base64url')}.${Buffer.from(sealed).toString('base64url')}`;
|
|
211
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* The key a seal holds, tried with each secret in turn (the current one first, then
|
|
216
|
+
* SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), and which one opened it: 0 for the current secret. Null when
|
|
217
|
+
* none of them sealed it for this tenant.
|
|
218
|
+
*/
|
|
219
|
+
export async function openSlotlockAssistantKeyWithAny(secrets, tenantRef, sealed) {
|
|
220
|
+
for (const [secretIndex, secret] of secrets.entries()) {
|
|
221
|
+
const apiKey = await openSlotlockAssistantKey(secret, tenantRef, sealed);
|
|
222
|
+
if (apiKey !== null)
|
|
223
|
+
return { apiKey, secretIndex };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
212
227
|
/** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
|
|
213
228
|
export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
|
|
214
229
|
const [version, iv, body, ...rest] = sealed.split('.');
|
|
@@ -226,7 +241,6 @@ export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
|
|
|
226
241
|
return null;
|
|
227
242
|
}
|
|
228
243
|
}
|
|
229
|
-
// ---- Settings ----
|
|
230
244
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
231
245
|
/** Whether a URL's host names this machine or a private network by itself: `localhost`, or an IP literal isPrivateAddress refuses. */
|
|
232
246
|
function namesPrivateHost(url) {
|
|
@@ -236,11 +250,10 @@ function namesPrivateHost(url) {
|
|
|
236
250
|
return isIP(host) !== 0 && isPrivateAddress(host);
|
|
237
251
|
}
|
|
238
252
|
/**
|
|
239
|
-
* A provider base URL as stored: https, or http on localhost or 127.0.0.1
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* to one is refused when it is resolved (checkSlotlockAssistantHost, and at each connection).
|
|
253
|
+
* A provider base URL as stored: https, or http on localhost or 127.0.0.1; no credentials, query,
|
|
254
|
+
* fragment or trailing slash. Unless `privateUrls`, a host that is `localhost` or a private-range
|
|
255
|
+
* IP literal is refused as `private`; a name resolving to one is refused when resolved
|
|
256
|
+
* (checkSlotlockAssistantHost, and at each connection).
|
|
244
257
|
*/
|
|
245
258
|
export function readSlotlockAssistantBaseUrl(value, options = {}) {
|
|
246
259
|
let url;
|
|
@@ -263,10 +276,9 @@ export function readSlotlockAssistantBaseUrl(value, options = {}) {
|
|
|
263
276
|
return { ok: true, url: href };
|
|
264
277
|
}
|
|
265
278
|
/**
|
|
266
|
-
* Whether a base URL's host resolves only to public addresses
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* here: the connection fails later, plainly.
|
|
279
|
+
* Whether a base URL's host resolves only to public addresses (`resolve`: dns.lookup on Node.js).
|
|
280
|
+
* True with `privateUrls` or an IP literal already checked; a name that does not resolve is not
|
|
281
|
+
* refused here: the connection fails later.
|
|
270
282
|
*/
|
|
271
283
|
export async function checkSlotlockAssistantHost(baseUrl, options) {
|
|
272
284
|
if (options.privateUrls === true || !options.resolve)
|
|
@@ -316,7 +328,6 @@ export function slotlockAssistantTools() {
|
|
|
316
328
|
];
|
|
317
329
|
});
|
|
318
330
|
}
|
|
319
|
-
// ---- Providers ----
|
|
320
331
|
/** Why a provider call failed, in words safe to show (never the key, never the provider's body). */
|
|
321
332
|
export class SlotlockAssistantProviderError extends Error {
|
|
322
333
|
reason;
|
|
@@ -473,7 +484,7 @@ function readOpenai(body) {
|
|
|
473
484
|
catch {
|
|
474
485
|
input = null;
|
|
475
486
|
}
|
|
476
|
-
// Some local servers
|
|
487
|
+
// Some local servers omit the id; one is needed to pair the result.
|
|
477
488
|
const id = typeof call.id === 'string' && call.id !== '' ? call.id : `call_${index}`;
|
|
478
489
|
calls.push({ id, name: call.function.name, input });
|
|
479
490
|
}
|
|
@@ -523,7 +534,7 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
523
534
|
method: 'POST',
|
|
524
535
|
headers,
|
|
525
536
|
body: JSON.stringify(anthropic ? anthropicBody(request) : openaiBody(request)),
|
|
526
|
-
//
|
|
537
|
+
// Never follow a redirect: it would carry the key elsewhere.
|
|
527
538
|
redirect: 'manual',
|
|
528
539
|
signal: request.signal,
|
|
529
540
|
});
|
|
@@ -562,6 +573,95 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
562
573
|
const turn = anthropic ? readAnthropic(body) : readOpenai(body);
|
|
563
574
|
return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
|
|
564
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
|
|
578
|
+
* for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
|
|
579
|
+
*/
|
|
580
|
+
export const SLOTLOCK_ASSISTANT_PRESETS = Object.freeze([
|
|
581
|
+
{ id: 'anthropic', name: 'Anthropic', provider: 'anthropic', baseUrl: 'https://api.anthropic.com' },
|
|
582
|
+
{ id: 'openai', name: 'OpenAI', provider: 'openai', baseUrl: 'https://api.openai.com/v1' },
|
|
583
|
+
{ id: 'openrouter', name: 'OpenRouter', provider: 'openai', baseUrl: 'https://openrouter.ai/api/v1' },
|
|
584
|
+
{
|
|
585
|
+
id: 'google',
|
|
586
|
+
name: 'Google',
|
|
587
|
+
provider: 'openai',
|
|
588
|
+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
589
|
+
},
|
|
590
|
+
{ id: 'mistral', name: 'Mistral', provider: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
|
|
591
|
+
{ id: 'groq', name: 'Groq', provider: 'openai', baseUrl: 'https://api.groq.com/openai/v1' },
|
|
592
|
+
{ id: 'together', name: 'Together', provider: 'openai', baseUrl: 'https://api.together.xyz/v1' },
|
|
593
|
+
{ id: 'custom', name: 'Custom', provider: 'openai', baseUrl: '' },
|
|
594
|
+
]);
|
|
595
|
+
/** Models one list keeps: a provider may offer hundreds. */
|
|
596
|
+
const MAX_LISTED_MODELS = 500;
|
|
597
|
+
/**
|
|
598
|
+
* The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
|
|
599
|
+
* OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
|
|
600
|
+
* form's Model field. Throws
|
|
601
|
+
* SlotlockAssistantProviderError as a model call does, never with the key in its message.
|
|
602
|
+
*/
|
|
603
|
+
export async function listSlotlockAssistantModels(args) {
|
|
604
|
+
const anthropic = args.provider === 'anthropic';
|
|
605
|
+
const headers = { accept: 'application/json', 'user-agent': 'slotlock-dashboard-assistant' };
|
|
606
|
+
if (anthropic) {
|
|
607
|
+
headers['anthropic-version'] = SLOTLOCK_ANTHROPIC_VERSION;
|
|
608
|
+
if (args.apiKey)
|
|
609
|
+
headers['x-api-key'] = args.apiKey;
|
|
610
|
+
}
|
|
611
|
+
else if (args.apiKey) {
|
|
612
|
+
headers.authorization = `Bearer ${args.apiKey}`;
|
|
613
|
+
}
|
|
614
|
+
const signal = AbortSignal.timeout(args.timeoutMs);
|
|
615
|
+
let response;
|
|
616
|
+
try {
|
|
617
|
+
response = await args.fetch(anthropic ? `${args.baseUrl}/v1/models?limit=1000` : `${args.baseUrl}/models`, {
|
|
618
|
+
method: 'GET',
|
|
619
|
+
headers,
|
|
620
|
+
// Never follow a redirect: it would carry the key elsewhere.
|
|
621
|
+
redirect: 'manual',
|
|
622
|
+
signal,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const name = error?.name;
|
|
627
|
+
if (error?.code === 'private_address') {
|
|
628
|
+
throw new SlotlockAssistantProviderError('private_address');
|
|
629
|
+
}
|
|
630
|
+
throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || signal.aborted ? 'timeout' : 'unavailable');
|
|
631
|
+
}
|
|
632
|
+
if (!response.ok) {
|
|
633
|
+
await response.body?.cancel().catch(() => undefined);
|
|
634
|
+
const status = response.status;
|
|
635
|
+
throw new SlotlockAssistantProviderError(status === 401 || status === 403
|
|
636
|
+
? 'refused_key'
|
|
637
|
+
: status === 404
|
|
638
|
+
? 'not_found'
|
|
639
|
+
: status === 429
|
|
640
|
+
? 'rate_limited'
|
|
641
|
+
: 'unavailable', status);
|
|
642
|
+
}
|
|
643
|
+
let body;
|
|
644
|
+
try {
|
|
645
|
+
body = JSON.parse(await readBounded(response));
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
if (error instanceof SlotlockAssistantProviderError)
|
|
649
|
+
throw error;
|
|
650
|
+
throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
|
|
651
|
+
}
|
|
652
|
+
// `{ data: [...] }` as OpenAI and Anthropic answer, or the bare array some OpenAI-compatible
|
|
653
|
+
// providers (Together) answer instead.
|
|
654
|
+
const listed = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.data) ? body.data : null;
|
|
655
|
+
if (!listed)
|
|
656
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
657
|
+
const ids = new Set();
|
|
658
|
+
for (const entry of listed) {
|
|
659
|
+
const id = isRecord(entry) ? entry.id : undefined;
|
|
660
|
+
if (typeof id === 'string' && SLOTLOCK_ASSISTANT_MODEL_NAME.test(id))
|
|
661
|
+
ids.add(id);
|
|
662
|
+
}
|
|
663
|
+
return [...ids].sort().slice(0, MAX_LISTED_MODELS);
|
|
664
|
+
}
|
|
565
665
|
/**
|
|
566
666
|
* The connection test: one request offering one read tool the model must call. True when it
|
|
567
667
|
* answers with exactly one call of that tool with object arguments.
|
|
@@ -675,24 +775,22 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
675
775
|
return { added };
|
|
676
776
|
}
|
|
677
777
|
/**
|
|
678
|
-
* The conversation within
|
|
679
|
-
*
|
|
680
|
-
* kept without the call it answers).
|
|
778
|
+
* The conversation within MAX_ENTRIES and the stored-bytes bound, oldest exchanges dropped whole,
|
|
779
|
+
* so it always starts at a member's message (a tool result is never kept without its call).
|
|
681
780
|
*/
|
|
682
781
|
export function boundSlotlockAssistantEntries(entries) {
|
|
683
|
-
//
|
|
782
|
+
// jsonb holds no NUL character: one would fail the write.
|
|
684
783
|
let kept = JSON.parse(JSON.stringify(entries, (_key, value) => typeof value === 'string' ? value.replaceAll('\u0000', '') : value));
|
|
685
784
|
const tooBig = () => kept.length > MAX_ENTRIES ||
|
|
686
785
|
Buffer.byteLength(JSON.stringify(kept), 'utf8') > SLOTLOCK_ASSISTANT_MAX_STORED_BYTES;
|
|
687
|
-
// The oldest exchanges first, whole.
|
|
688
786
|
while (tooBig()) {
|
|
689
787
|
const next = kept.findIndex((entry, index) => index > 0 && entry.role === 'user');
|
|
690
788
|
if (next === -1)
|
|
691
789
|
break;
|
|
692
790
|
kept = kept.slice(next);
|
|
693
791
|
}
|
|
694
|
-
//
|
|
695
|
-
//
|
|
792
|
+
// An exchange too big alone loses its steps from the oldest, non-proposing ones first; the
|
|
793
|
+
// member's message and the last answer stay.
|
|
696
794
|
for (const keepProposals of [true, false]) {
|
|
697
795
|
while (tooBig()) {
|
|
698
796
|
const step = kept.findIndex((entry, index) => entry.role === 'assistant' &&
|
|
@@ -711,8 +809,8 @@ export function boundSlotlockAssistantEntries(entries) {
|
|
|
711
809
|
return tooBig() ? [] : kept;
|
|
712
810
|
}
|
|
713
811
|
/**
|
|
714
|
-
* The actions a conversation keeps: those its
|
|
715
|
-
*
|
|
812
|
+
* The actions a conversation keeps: those its entries show, oldest settled ones dropped first past
|
|
813
|
+
* MAX_ACTIONS_BYTES (waiting ones are bounded by SLOTLOCK_ASSISTANT_MAX_PENDING).
|
|
716
814
|
*/
|
|
717
815
|
export function pruneSlotlockAssistantActions(entries, actions) {
|
|
718
816
|
const shown = [];
|
package/billing.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type SlotlockPlan, type SlotlockPlanStatus } from './plans.js';
|
|
2
|
+
import type { SlotlockSql } from './store.js';
|
|
3
|
+
export type SlotlockBillingInterval = 'month' | 'year';
|
|
4
|
+
/**
|
|
5
|
+
* Stripe answered with an error, or did not answer, or (`subscription_exists`) the customer already
|
|
6
|
+
* pays for a subscription. The message never holds the key.
|
|
7
|
+
*/
|
|
8
|
+
export declare class SlotlockBillingError extends Error {
|
|
9
|
+
readonly status?: number | undefined;
|
|
10
|
+
readonly code?: 'subscription_exists' | undefined;
|
|
11
|
+
constructor(message: string, status?: number | undefined, code?: 'subscription_exists' | undefined);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The Stripe-Version every request sends unless `apiVersion` says otherwise: Managed Payments needs
|
|
15
|
+
* 2025-03-31.basil or later and became generally available in this one (docs.stripe.com changelog,
|
|
16
|
+
* read 2026-11-09). Pinned, so the account's default version never changes what is sent or read.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SLOTLOCK_STRIPE_API_VERSION = "2026-04-22.dahlia";
|
|
19
|
+
export interface SlotlockStripeOptions {
|
|
20
|
+
/** A restricted key: write on Checkout Sessions and portal sessions, read on Subscriptions. */
|
|
21
|
+
secretKey: string;
|
|
22
|
+
/** The Developer plan's price ids, monthly and yearly. */
|
|
23
|
+
prices: Readonly<Record<SlotlockBillingInterval, string>>;
|
|
24
|
+
/** The Team plan's price ids, monthly and yearly (Teams on Cloud): an organization's checkout. */
|
|
25
|
+
teamPrices?: Readonly<Record<SlotlockBillingInterval, string>>;
|
|
26
|
+
/**
|
|
27
|
+
* Metered overage (overage.ts, SLOTLOCK_OVERAGE): a metered price on a Billing Meter, added to a
|
|
28
|
+
* Developer checkout so operations above the quota can be billed. Off without it.
|
|
29
|
+
*/
|
|
30
|
+
overagePrice?: string;
|
|
31
|
+
/** Sent as Stripe-Version; SLOTLOCK_STRIPE_API_VERSION when unset. */
|
|
32
|
+
apiVersion?: string;
|
|
33
|
+
apiBase?: string;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/** The parts of a Stripe subscription the plan is read from. */
|
|
38
|
+
export interface SlotlockStripeSubscription {
|
|
39
|
+
id: string;
|
|
40
|
+
status: string;
|
|
41
|
+
customer: string | {
|
|
42
|
+
id: string;
|
|
43
|
+
};
|
|
44
|
+
metadata?: Record<string, string>;
|
|
45
|
+
current_period_end?: number | null;
|
|
46
|
+
items?: {
|
|
47
|
+
data?: Array<{
|
|
48
|
+
current_period_end?: number | null;
|
|
49
|
+
price?: {
|
|
50
|
+
id?: string;
|
|
51
|
+
} | string | null;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export interface SlotlockStripe {
|
|
56
|
+
/** A Checkout URL for a Developer (or, with team prices, Team) subscription, tagged with the tenant. */
|
|
57
|
+
checkout(input: {
|
|
58
|
+
tenantRef: string;
|
|
59
|
+
interval: SlotlockBillingInterval;
|
|
60
|
+
/** Developer unless `team`, which needs the Team prices. */
|
|
61
|
+
plan?: 'developer' | 'team';
|
|
62
|
+
customer: string | null;
|
|
63
|
+
successUrl: string;
|
|
64
|
+
cancelUrl: string;
|
|
65
|
+
}): Promise<string>;
|
|
66
|
+
/** A customer portal URL, to change or cancel the subscription and see invoices. */
|
|
67
|
+
portal(input: {
|
|
68
|
+
customer: string;
|
|
69
|
+
returnUrl: string;
|
|
70
|
+
}): Promise<string>;
|
|
71
|
+
subscription(id: string): Promise<SlotlockStripeSubscription>;
|
|
72
|
+
/** The customer's subscriptions that give a plan (`active`, `trialing`, `past_due`). */
|
|
73
|
+
liveSubscriptionsOfCustomer(customer: string): Promise<SlotlockStripeSubscription[]>;
|
|
74
|
+
/**
|
|
75
|
+
* The tenant's subscriptions that give a plan, found by their `tenant_ref` metadata under any
|
|
76
|
+
* customer (Stripe's search, which can lag a minute behind a subscription just created).
|
|
77
|
+
*/
|
|
78
|
+
liveSubscriptionsOfTenant(tenantRef: string): Promise<SlotlockStripeSubscription[]>;
|
|
79
|
+
/** The plan a subscription gives, by its price: Team for a Team price, else Developer. */
|
|
80
|
+
planOf?(subscription: SlotlockStripeSubscription): {
|
|
81
|
+
plan: SlotlockPlan;
|
|
82
|
+
status: SlotlockPlanStatus;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Whether the subscription gives its tenant a plan. */
|
|
86
|
+
export declare function slotlockSubscriptionIsLive(subscription: SlotlockStripeSubscription): boolean;
|
|
87
|
+
export declare function createSlotlockStripe(options: SlotlockStripeOptions): SlotlockStripe;
|
|
88
|
+
/**
|
|
89
|
+
* Whether a webhook's body was signed by Stripe with this endpoint's secret
|
|
90
|
+
* (docs.stripe.com/webhooks#verify-manually): `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
|
|
91
|
+
* any v1 matching, within `toleranceSeconds` of now.
|
|
92
|
+
*/
|
|
93
|
+
export declare function verifySlotlockStripeSignature(body: string, header: string | null, secret: string, nowMs: number, toleranceSeconds?: number): boolean;
|
|
94
|
+
/** The plan a subscription gives its tenant. */
|
|
95
|
+
export declare function slotlockPlanOfSubscription(subscription: SlotlockStripeSubscription, teamPrices?: Readonly<Record<SlotlockBillingInterval, string>>): {
|
|
96
|
+
plan: SlotlockPlan;
|
|
97
|
+
status: SlotlockPlanStatus;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Handles one Stripe webhook delivery: checks its signature, then, for any event about a
|
|
101
|
+
* subscription, reads that subscription from Stripe (not the event, which may be stale or arrive out
|
|
102
|
+
* of order) and sets its tenant's plan. A tenant that bought twice (two tabs, before either webhook
|
|
103
|
+
* arrived) keeps Developer until its last live subscription ends, and the second purchase is
|
|
104
|
+
* reported through `onDuplicate`, by subscription ids only, for a refund. Answers 400 to an unsigned
|
|
105
|
+
* or unreadable body, 500 when Stripe or the database fails (Stripe retries), and 200 otherwise.
|
|
106
|
+
*/
|
|
107
|
+
export declare function handleSlotlockStripeWebhook(request: Request, deps: {
|
|
108
|
+
secret: string;
|
|
109
|
+
stripe: SlotlockStripe;
|
|
110
|
+
sql: SlotlockSql;
|
|
111
|
+
now?: () => number;
|
|
112
|
+
onError?: (error: unknown) => void;
|
|
113
|
+
onDuplicate?: (subscriptionIds: readonly string[]) => void;
|
|
114
|
+
}): Promise<Response>;
|