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/assistant.js CHANGED
@@ -1,27 +1,23 @@
1
- // The dashboard assistant: an organization connects a model of its own (the Anthropic Messages API,
2
- // or any OpenAI-compatible chat completions endpoint: OpenAI, OpenRouter, vLLM, Ollama, LM Studio)
3
- // and its members operate Slotlock by chat. The server runs the tool loop: it calls the model with a
4
- // fixed list of agent tools, runs reads through the agent operation boundary (schemas, scopes,
5
- // authorization and limits, as for any agent) and turns every write into an action the member
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: the Messages API (POST <base>/v1/messages, x-api-key, anthropic-version; tool_use
9
- // and tool_result blocks) and Chat Completions (POST <base>/chat/completions, Bearer; tool_calls with
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, in Node.js and Workers alike) under a key
14
- // derived by HKDF-SHA-256 from the deployment's SLOTLOCK_ASSISTANT_SECRET, with the tenant as
15
- // additional data: a sealed key copied to another tenant's row does not open.
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 one a later
21
- * release adds does not compile until it is placed here. Reads run when the model calls them;
22
- * every write is only proposed, and runs when the member approves it. API keys, webhooks, booking
23
- * rules, pools, erasure and coverage are application operations, never agent ones, so no entry can
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, past which the oldest exchanges go first.
56
- * PostgreSQL's jsonb text adds at most one space per `:` or `,`, so at most half again: under the
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
- // Each read deletes the tenant's expired conversations first, so they go on a deployment whose
94
- // maintenance never meets the tenant (a Worker's) too.
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: the stored ones kept as they stand, then the new ones not stored already.
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 (a model on the server's
240
- * own machine); no credentials, query or fragment; no trailing slash. Unless `privateUrls`, a URL
241
- * whose host is `localhost` or a loopback, private, link-local, shared (CGNAT), unique-local or
242
- * metadata address (the webhooks' isPrivateAddress) is refused as `private`; a name that resolves
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: `resolve` lists them (dns.lookup's,
267
- * on Node.js). True when `privateUrls`, or when the host is an IP literal readSlotlockAssistantBaseUrl
268
- * already checked; false when any address is private. A name that does not resolve is not refused
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 leave the id out; one is needed to pair the result.
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
- // A redirect is not followed: it would carry the key elsewhere.
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 its bounds: at most MAX_ENTRIES entries and MAX_STORED_CHARACTERS, the
679
- * oldest exchanges dropped whole, so it always starts at a member's message (a tool result is never
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
- // PostgreSQL's jsonb holds no NUL character: one would fail the write.
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
- // One exchange too big alone: its steps (a call and its results) from the oldest, those that
695
- // proposed no change first; the member's message and the last answer stay.
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 kept entries show, the oldest settled ones dropped
715
- * first past MAX_ACTIONS_BYTES (a waiting one is kept: SLOTLOCK_ASSISTANT_MAX_PENDING bounds them).
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>;