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/partners.js ADDED
@@ -0,0 +1,375 @@
1
+ import { SlotlockAgentOperationError } from './agent-server.js';
2
+ import { SLOTLOCK_API_KEY_SCOPES, generateSlotlockPartnerKey, isSlotlockPartnerKey, slotlockApiKeyDigest, slotlockBearerCredential, } from './api-keys.js';
3
+ import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_USAGE_RETENTION_DAYS } from './ddl.js';
4
+ import { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_PLAN, SLOTLOCK_PARTNER_SCOPE, } from './partner-names.js';
5
+ export { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_PLAN, SLOTLOCK_PARTNER_SCOPE };
6
+ /** The tenant a partner's account is. */
7
+ export function slotlockPartnerTenantRef(partnerId, account) {
8
+ return `partner:${partnerId}:${account}`;
9
+ }
10
+ function contractError(message) {
11
+ return Object.assign(new Error(`Slotlock partner contract: ${message}`), {
12
+ code: 'invalid_partner_contract',
13
+ });
14
+ }
15
+ const whole = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
16
+ /**
17
+ * Read a contract as the owner writes it (snake_case JSON, as the database keeps it), refusing one
18
+ * the database would not take: `invalid_partner_contract`.
19
+ */
20
+ export function parseSlotlockPartnerContract(value) {
21
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
22
+ throw contractError('must be an object');
23
+ const given = value;
24
+ const plansGiven = given.plans;
25
+ if (typeof plansGiven !== 'object' || plansGiven === null || Array.isArray(plansGiven)) {
26
+ throw contractError('plans must be an object of plans by name');
27
+ }
28
+ const entries = Object.entries(plansGiven);
29
+ if (entries.length === 0 || entries.length > 20)
30
+ throw contractError('needs 1-20 plans');
31
+ const plans = {};
32
+ for (const [name, plan] of entries) {
33
+ if (!SLOTLOCK_PARTNER_PLAN.test(name))
34
+ throw contractError(`plan name ${JSON.stringify(name)} is not a plan name`);
35
+ const fields = (plan ?? {});
36
+ const limits = {
37
+ resources: fields.resources,
38
+ apiKeys: fields.api_keys,
39
+ webhookEndpoints: fields.webhook_endpoints,
40
+ operations: fields.operations,
41
+ bookings: fields.bookings,
42
+ stopPercent: fields.stop_percent ?? 100,
43
+ };
44
+ if (!whole(limits.resources, 1, 100_000) ||
45
+ !whole(limits.apiKeys, 1, SLOTLOCK_API_KEY_ACTIVE_LIMIT) ||
46
+ !whole(limits.webhookEndpoints, 1, 1_000) ||
47
+ !whole(limits.operations, 1, 1_000_000_000_000) ||
48
+ !whole(limits.bookings, 1, 1_000_000_000_000) ||
49
+ !whole(limits.stopPercent, 100, 1_000)) {
50
+ throw contractError(`plan ${name} needs resources (1-100,000), api_keys (1-${SLOTLOCK_API_KEY_ACTIVE_LIMIT}), webhook_endpoints (1-1,000), operations and bookings (whole, at least 1) and stop_percent (100-1,000)`);
51
+ }
52
+ plans[name] = limits;
53
+ }
54
+ const scopesGiven = given.key_scopes ?? ['read', 'write'];
55
+ if (!Array.isArray(scopesGiven) ||
56
+ scopesGiven.length === 0 ||
57
+ scopesGiven.some((scope) => !SLOTLOCK_API_KEY_SCOPES.includes(scope))) {
58
+ throw contractError(`key_scopes must list some of ${SLOTLOCK_API_KEY_SCOPES.join(', ')}`);
59
+ }
60
+ const contract = {
61
+ plans,
62
+ keyScopes: SLOTLOCK_API_KEY_SCOPES.filter((scope) => scopesGiven.includes(scope)),
63
+ };
64
+ if (given.act_on_behalf !== undefined) {
65
+ if (typeof given.act_on_behalf !== 'boolean')
66
+ throw contractError('act_on_behalf must be true or false');
67
+ if (given.act_on_behalf)
68
+ contract.actOnBehalf = true;
69
+ }
70
+ if (given.pricing !== undefined) {
71
+ const pricing = (given.pricing ?? {});
72
+ const parsed = {
73
+ currency: pricing.currency,
74
+ activeAccount: pricing.active_account ?? 0,
75
+ operationsOverQuotaPer10k: pricing.operations_over_quota_per_10k ?? 0,
76
+ bookingsOverQuotaPer100: pricing.bookings_over_quota_per_100 ?? 0,
77
+ minimum: pricing.minimum ?? 0,
78
+ };
79
+ if (typeof parsed.currency !== 'string' ||
80
+ !/^[a-z]{3}$/.test(parsed.currency) ||
81
+ !whole(parsed.activeAccount, 0, 100_000_000) ||
82
+ !whole(parsed.operationsOverQuotaPer10k, 0, 100_000_000) ||
83
+ !whole(parsed.bookingsOverQuotaPer100, 0, 100_000_000) ||
84
+ !whole(parsed.minimum, 0, 1_000_000_000)) {
85
+ throw contractError('pricing needs a currency (ISO 4217, lowercase) and whole amounts in its minor units: active_account, operations_over_quota_per_10k, bookings_over_quota_per_100, minimum');
86
+ }
87
+ contract.pricing = parsed;
88
+ }
89
+ return contract;
90
+ }
91
+ const partnerRefusal = (code, status, message) => new SlotlockAgentOperationError(code, status, message ? { message } : undefined);
92
+ /** The partner a principal acts for: a partner key's, or nothing (never a tenant's key). */
93
+ export function slotlockPrincipalPartner(principal) {
94
+ const partner = principal.partnerId;
95
+ if (typeof partner !== 'string' ||
96
+ !SLOTLOCK_PARTNER_ID.test(partner) ||
97
+ !Array.isArray(principal.scopes) ||
98
+ !principal.scopes.includes(SLOTLOCK_PARTNER_SCOPE)) {
99
+ throw partnerRefusal('forbidden', 403);
100
+ }
101
+ return partner;
102
+ }
103
+ const toAccount = (row) => ({
104
+ account: row.account,
105
+ plan: row.plan,
106
+ status: row.suspended ? 'suspended' : 'active',
107
+ createdAt: row.created_at,
108
+ updatedAt: row.updated_at,
109
+ ...(row.resources !== undefined ? { resources: Number(row.resources) } : {}),
110
+ ...(row.api_keys !== undefined ? { apiKeys: Number(row.api_keys) } : {}),
111
+ });
112
+ /**
113
+ * The partner store over the serving role, through SLOTLOCK_PARTNER_FUNCTIONS. `keys` is the API
114
+ * key store; `eraseTenant` the store's erasure (a tenant's whole data, as `/v1/tenant/erase`).
115
+ */
116
+ export function createSlotlockPartnerStore(sql, deps) {
117
+ const tenantOf = async (partnerId, account) => {
118
+ if (!SLOTLOCK_PARTNER_ID.test(partnerId) || !SLOTLOCK_PARTNER_ACCOUNT.test(account))
119
+ return null;
120
+ const [row] = await sql `
121
+ SELECT tenant_ref FROM slotlock.get_partner_account(${partnerId}::text, ${account}::text)`;
122
+ return row?.tenant_ref ?? null;
123
+ };
124
+ return {
125
+ async contract(partnerId) {
126
+ if (!SLOTLOCK_PARTNER_ID.test(partnerId))
127
+ return null;
128
+ const [row] = await sql `
129
+ SELECT slotlock.partner_contract(${partnerId}::text) AS contract`;
130
+ return row?.contract ? parseSlotlockPartnerContract(row.contract) : null;
131
+ },
132
+ async putAccount(partnerId, account, plan) {
133
+ try {
134
+ const [row] = await sql `
135
+ SELECT put.account, put.plan, false AS suspended, put.created_at, put.updated_at, put.created
136
+ FROM slotlock.put_partner_account(${partnerId}::text, ${account}::text, ${plan}::text) put`;
137
+ if (!row)
138
+ return null;
139
+ const found = await this.getAccount(partnerId, account);
140
+ return { account: found ?? toAccount(row), created: row.created };
141
+ }
142
+ catch (error) {
143
+ const hint = error.hint;
144
+ if (hint === 'invalid_partner_plan' || hint === 'invalid_partner_account') {
145
+ throw partnerRefusal(hint, 400);
146
+ }
147
+ throw error;
148
+ }
149
+ },
150
+ async listAccounts(partnerId) {
151
+ const rows = await sql `
152
+ SELECT account, plan, suspended, created_at, updated_at
153
+ FROM slotlock.list_partner_accounts(${partnerId}::text)`;
154
+ return rows.map(toAccount);
155
+ },
156
+ async getAccount(partnerId, account) {
157
+ if (!SLOTLOCK_PARTNER_ACCOUNT.test(account))
158
+ return null;
159
+ const [row] = await sql `
160
+ SELECT account, plan, suspended, created_at, updated_at, resources::text, api_keys::text
161
+ FROM slotlock.get_partner_account(${partnerId}::text, ${account}::text)`;
162
+ return row ? toAccount(row) : null;
163
+ },
164
+ tenantOf,
165
+ async setSuspended(partnerId, account, suspended, reason) {
166
+ if (!SLOTLOCK_PARTNER_ACCOUNT.test(account))
167
+ return null;
168
+ const [row] = await sql `
169
+ SELECT slotlock.set_partner_account_suspended(
170
+ ${partnerId}::text, ${account}::text, ${suspended}::boolean, ${reason ?? null}::text) AS changed`;
171
+ return row?.changed ?? null;
172
+ },
173
+ async usage(partnerId, from, to, account) {
174
+ const rows = await sql `
175
+ SELECT account, day::text, metric, count::text
176
+ FROM slotlock.partner_usage(${partnerId}::text, ${from}::date, ${to}::date, ${account ?? null}::text)`;
177
+ return rows.map((row) => ({ account: row.account, day: row.day, metric: row.metric, count: Number(row.count) }));
178
+ },
179
+ async recordReconciliation(partnerId, day, accounts, mismatched, worstPercent) {
180
+ await sql `SELECT slotlock.record_partner_reconciliation(${partnerId}::text, ${day}::date,
181
+ ${accounts}::integer, ${mismatched}::integer, ${worstPercent}::numeric)`;
182
+ },
183
+ keys: deps.keys,
184
+ async eraseAccount(partnerId, account) {
185
+ const tenantRef = await tenantOf(partnerId, account);
186
+ if (!tenantRef)
187
+ return { erased: false, total: 0 };
188
+ const erased = await deps.eraseTenant(tenantRef);
189
+ return { erased: true, total: erased.total };
190
+ },
191
+ };
192
+ }
193
+ /**
194
+ * Bearer authentication by partner key: a request with an active partner's unrevoked key is the
195
+ * principal `partner_key:<id>` with the `partner` scope and its `partnerId`, in a tenant of its own
196
+ * (`partner:<partner>`) that holds no data. Anything else is not a partner key (null), for the
197
+ * tenant authenticator to try.
198
+ */
199
+ export function createSlotlockPartnerKeyAuthenticator(sql) {
200
+ const contracts = new Map();
201
+ return async (request) => {
202
+ const presented = slotlockBearerCredential(request);
203
+ if (presented === undefined || !isSlotlockPartnerKey(presented))
204
+ return null;
205
+ const account = request.headers.get(SLOTLOCK_ACCOUNT_FIELD);
206
+ // A malformed account is refused before the key is looked up, as a malformed key is.
207
+ if (account !== null && !SLOTLOCK_PARTNER_ACCOUNT.test(account))
208
+ return null;
209
+ // One statement: the key, and with Slotlock-Account its partner's contract and the account's tenant.
210
+ const [row] = await sql `
211
+ SELECT authenticated.key_id::text, authenticated.partner_id,
212
+ CASE WHEN ${account}::text IS NULL THEN NULL
213
+ ELSE slotlock.partner_contract(authenticated.partner_id)::text END AS contract,
214
+ CASE WHEN ${account}::text IS NULL THEN NULL
215
+ ELSE slotlock.partner_account_tenant(authenticated.partner_id, ${account}::text) END AS tenant_ref
216
+ FROM slotlock.authenticate_partner_key(${slotlockApiKeyDigest(presented)}::bytea) authenticated`;
217
+ if (!row)
218
+ return null;
219
+ if (account === null) {
220
+ return {
221
+ subject: `partner_key:${row.key_id}`,
222
+ tenantRef: `partner:${row.partner_id}`,
223
+ scopes: [SLOTLOCK_PARTNER_SCOPE],
224
+ partnerId: row.partner_id,
225
+ };
226
+ }
227
+ // Acting for one of its accounts (RFC 0003 P3): only where the contract allows it, only for an
228
+ // account the partner has, and with no more than the contract lets its accounts' keys carry.
229
+ if (!row.contract || !row.tenant_ref)
230
+ return null;
231
+ const contract = parsedContract(row.contract);
232
+ if (!contract?.actOnBehalf)
233
+ return null;
234
+ return {
235
+ subject: `partner_key:${row.key_id}`,
236
+ tenantRef: row.tenant_ref,
237
+ scopes: [...contract.keyScopes],
238
+ onBehalfOf: row.partner_id,
239
+ };
240
+ };
241
+ /** Contracts read, by their JSON text: parsed once while unchanged (at most 1,000 kept). */
242
+ function parsedContract(text) {
243
+ const kept = contracts.get(text);
244
+ if (kept !== undefined)
245
+ return kept;
246
+ let contract;
247
+ try {
248
+ contract = parseSlotlockPartnerContract(JSON.parse(text));
249
+ }
250
+ catch {
251
+ contract = null;
252
+ }
253
+ if (contracts.size >= 1_000)
254
+ contracts.delete(contracts.keys().next().value);
255
+ contracts.set(text, contract);
256
+ return contract;
257
+ }
258
+ }
259
+ /** The request field a partner key names one of its accounts in, to act for it (RFC 0003 P3). */
260
+ export const SLOTLOCK_ACCOUNT_FIELD = 'slotlock-account';
261
+ const toPartner = (row) => ({
262
+ id: row.id,
263
+ name: row.name,
264
+ status: row.status,
265
+ contract: parseSlotlockPartnerContract(row.contract),
266
+ stripeCustomer: row.stripe_customer,
267
+ webhookUrl: row.webhook_url,
268
+ createdAt: row.created_at,
269
+ });
270
+ /** The contract as the database keeps it: snake_case, as the owner writes it. */
271
+ export function slotlockPartnerContractJson(contract) {
272
+ return {
273
+ plans: Object.fromEntries(Object.entries(contract.plans).map(([name, plan]) => [
274
+ name,
275
+ {
276
+ resources: plan.resources,
277
+ api_keys: plan.apiKeys,
278
+ webhook_endpoints: plan.webhookEndpoints,
279
+ operations: plan.operations,
280
+ bookings: plan.bookings,
281
+ stop_percent: plan.stopPercent,
282
+ },
283
+ ])),
284
+ key_scopes: [...contract.keyScopes],
285
+ ...(contract.actOnBehalf ? { act_on_behalf: true } : {}),
286
+ ...(contract.pricing
287
+ ? {
288
+ pricing: {
289
+ currency: contract.pricing.currency,
290
+ active_account: contract.pricing.activeAccount,
291
+ operations_over_quota_per_10k: contract.pricing.operationsOverQuotaPer10k,
292
+ bookings_over_quota_per_100: contract.pricing.bookingsOverQuotaPer100,
293
+ minimum: contract.pricing.minimum,
294
+ },
295
+ }
296
+ : {}),
297
+ };
298
+ }
299
+ /** Create a partner, or change its name and contract (as the schema owner). */
300
+ export async function putSlotlockPartner(sql, input) {
301
+ if (!SLOTLOCK_PARTNER_ID.test(input.id)) {
302
+ throw Object.assign(new Error('Slotlock partner id must be 2-40 lowercase letters, digits or dashes, a letter first'), {
303
+ code: 'invalid_partner',
304
+ });
305
+ }
306
+ const contract = slotlockPartnerContractJson(parseSlotlockPartnerContract(input.contract));
307
+ const [row] = await sql `
308
+ INSERT INTO slotlock.partners (id, name, contract)
309
+ VALUES (${input.id}, ${input.name}, ${JSON.stringify(contract)}::text::jsonb)
310
+ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, contract = EXCLUDED.contract
311
+ RETURNING id, name, status, contract, stripe_customer, webhook_url, created_at`;
312
+ return toPartner(row);
313
+ }
314
+ export async function listSlotlockPartners(sql) {
315
+ const rows = await sql `
316
+ SELECT id, name, status, contract, stripe_customer, webhook_url, created_at
317
+ FROM slotlock.partners ORDER BY id`;
318
+ return rows.map(toPartner);
319
+ }
320
+ /** Suspend a partner (every account with it) or make it active again. False for no such partner. */
321
+ export async function setSlotlockPartnerStatus(sql, id, status) {
322
+ const rows = await sql `UPDATE slotlock.partners SET status = ${status} WHERE id = ${id} RETURNING id`;
323
+ return rows.length > 0;
324
+ }
325
+ /** Where the partner is billed (P6) and where usage.period_closed goes (P5), with its secret. */
326
+ export async function setSlotlockPartnerBilling(sql, id, input) {
327
+ const rows = await sql `
328
+ UPDATE slotlock.partners
329
+ SET stripe_customer = CASE WHEN ${input.stripeCustomer !== undefined} THEN ${input.stripeCustomer ?? null} ELSE stripe_customer END,
330
+ webhook_url = CASE WHEN ${input.webhookUrl !== undefined} THEN ${input.webhookUrl ?? null} ELSE webhook_url END,
331
+ webhook_secret = CASE WHEN ${input.webhookSecret !== undefined} THEN ${input.webhookSecret ?? null} ELSE webhook_secret END
332
+ WHERE id = ${id} RETURNING id`;
333
+ return rows.length > 0;
334
+ }
335
+ /** A key for the partner, shown once: only its SHA-256 is kept. */
336
+ export async function createSlotlockPartnerKey(sql, partnerId, name) {
337
+ const { key, prefix, digest } = generateSlotlockPartnerKey();
338
+ const [row] = await sql `
339
+ INSERT INTO slotlock.partner_keys (partner_id, name, prefix, secret_hash)
340
+ VALUES (${partnerId}, ${name}, ${prefix}, ${digest})
341
+ RETURNING id::text`;
342
+ return { key, id: row.id, prefix };
343
+ }
344
+ /** Revoke one of a partner's keys at once. False for no such key. */
345
+ export async function revokeSlotlockPartnerKey(sql, partnerId, id) {
346
+ const rows = await sql `
347
+ UPDATE slotlock.partner_keys SET revoked_at = COALESCE(revoked_at, now())
348
+ WHERE partner_id = ${partnerId} AND id = ${id}::uuid RETURNING id`;
349
+ return rows.length > 0;
350
+ }
351
+ /** The partner API's view of a key it made for one of its accounts. */
352
+ export function slotlockPartnerKeyOutput(apiKey) {
353
+ return {
354
+ id: apiKey.id,
355
+ name: apiKey.name,
356
+ prefix: apiKey.prefix,
357
+ scopes: apiKey.scopes,
358
+ created_at: apiKey.createdAt.toISOString(),
359
+ expires_at: apiKey.expiresAt?.toISOString() ?? null,
360
+ last_used_at: apiKey.lastUsedAt?.toISOString() ?? null,
361
+ revoked_at: apiKey.revokedAt?.toISOString() ?? null,
362
+ limits: apiKey.limits
363
+ ? {
364
+ ...(apiKey.limits.maxHolds !== undefined ? { max_holds: apiKey.limits.maxHolds } : {}),
365
+ ...(apiKey.limits.maxHeldHours !== undefined ? { max_held_hours: apiKey.limits.maxHeldHours } : {}),
366
+ ...(apiKey.limits.requestsPerMinute !== undefined
367
+ ? { requests_per_minute: apiKey.limits.requestsPerMinute }
368
+ : {}),
369
+ ...(apiKey.limits.requiresApproval ? { requires_approval: true } : {}),
370
+ }
371
+ : null,
372
+ };
373
+ }
374
+ /** The longest usage range the partner API reads, in days. */
375
+ export const SLOTLOCK_PARTNER_USAGE_DAYS = SLOTLOCK_USAGE_RETENTION_DAYS;
package/plans.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { SlotlockSql } from './store.js';
2
+ /**
3
+ * The plans hosted Slotlock sells. Custom runs on the customer's own servers and needs no row. Team
4
+ * is an organization's (Teams on Cloud): five times Developer's resources, more webhook endpoints,
5
+ * and four times its monthly quotas, shared by its members.
6
+ */
7
+ export type SlotlockPlan = 'free' | 'developer' | 'team';
8
+ /** Where the tenant's billing stands. A `canceled` or long-unpaid tenant is on the Free limits. */
9
+ export type SlotlockPlanStatus = 'active' | 'past_due' | 'canceled';
10
+ /** What a plan allows a tenant to hold at once. */
11
+ export interface SlotlockPlanLimits {
12
+ /** Resources, archived ones included (the store's own count). */
13
+ resources: number;
14
+ /** Active API keys. */
15
+ apiKeys: number;
16
+ /** Webhook endpoints. */
17
+ webhookEndpoints: number;
18
+ }
19
+ export declare const SLOTLOCK_PLAN_LIMITS: Readonly<Record<SlotlockPlan, Readonly<SlotlockPlanLimits>>>;
20
+ /**
21
+ * What a plan allows a tenant to use each UTC calendar month (quotas.ts, with SLOTLOCK_QUOTAS=on).
22
+ * They are the plan's, not stored per tenant: the Terms give 30 days' notice of a change, and the
23
+ * release that makes it applies it to every tenant on the plan.
24
+ */
25
+ export interface SlotlockPlanQuotas {
26
+ /** Operations its API keys and the assistant run (MCP, A2A and REST, the application API too). */
27
+ operations: number;
28
+ /** Bookings made: reservations, events (a whole series is one) and holds confirmed. */
29
+ bookings: number;
30
+ /** Where use is refused, in percent of the quota: 100 stops at it, 110 allows a tenth more. */
31
+ stopPercent: number;
32
+ }
33
+ export declare const SLOTLOCK_PLAN_QUOTAS: Readonly<Record<SlotlockPlan, Readonly<SlotlockPlanQuotas>>>;
34
+ /** A tenant's plan as the database holds it. */
35
+ export interface SlotlockTenantPlan {
36
+ plan: SlotlockPlan;
37
+ status: SlotlockPlanStatus;
38
+ limits: SlotlockPlanLimits;
39
+ stripeCustomer: string | null;
40
+ stripeSubscription: string | null;
41
+ currentPeriodEnd: Date | null;
42
+ }
43
+ /**
44
+ * The monthly quotas the tenant is held to (quotas.ts): its own (a partner's account, on its
45
+ * contract plan's numbers, RFC 0003 P4), else its plan's, else null (no plan, or one with none).
46
+ */
47
+ export declare function readSlotlockTenantQuotas(sql: SlotlockSql, tenantRef: string,
48
+ /**
49
+ * Metered overage (SLOTLOCK_OVERAGE=on, overage.ts): a paying Developer tenant is refused only past
50
+ * this percent of its quota, and what it uses above the quota is billed.
51
+ */
52
+ options?: {
53
+ overageStopPercent?: number;
54
+ }): Promise<SlotlockPlanQuotas | null>;
55
+ /** The tenant's plan, or null when it has none (no plan limits apply). */
56
+ export declare function readSlotlockTenantPlan(sql: SlotlockSql, tenantRef: string): Promise<SlotlockTenantPlan | null>;
57
+ /** Sets the tenant's plan, with the limits that plan allows. */
58
+ export declare function setSlotlockTenantPlan(sql: SlotlockSql, input: {
59
+ tenantRef: string;
60
+ plan: SlotlockPlan;
61
+ status: SlotlockPlanStatus;
62
+ stripeCustomer?: string | null;
63
+ stripeSubscription?: string | null;
64
+ currentPeriodEnd?: Date | null;
65
+ }): Promise<void>;
66
+ /** The plan every tenant without one of its own is on, from `appliesFrom`. */
67
+ export interface SlotlockPlanDefault {
68
+ plan: SlotlockPlan;
69
+ limits: SlotlockPlanLimits;
70
+ appliesFrom: Date;
71
+ }
72
+ /** The deployment's default plan, or null when it has none. */
73
+ export declare function readSlotlockPlanDefault(sql: SlotlockSql): Promise<SlotlockPlanDefault | null>;
74
+ /**
75
+ * Sets the default plan, with the limits that plan allows, or removes it (null). Only the deployment
76
+ * role may: another is refused EXECUTE (`plan_default_not_permitted`).
77
+ */
78
+ export declare function setSlotlockPlanDefault(sql: SlotlockSql, input: {
79
+ plan: SlotlockPlan;
80
+ appliesFrom: Date;
81
+ } | null): Promise<void>;
82
+ /**
83
+ * Puts a new tenant on the default plan at once, while the default does not yet apply to every
84
+ * tenant: one with no API key, nothing in its feed and no webhook. True when it did. The dashboard
85
+ * calls it before a tenant adds a key, resource or webhook endpoint.
86
+ */
87
+ export declare function enrolSlotlockTenantPlan(sql: SlotlockSql, tenantRef: string): Promise<boolean>;
88
+ /**
89
+ * Reading a tenant's plan needs EXECUTE on slotlock.tenant_plan(text), which `slotlock migrate` (or
90
+ * grantApplicationRole) grants: a role without it is told so (`application_role_not_granted`).
91
+ */
92
+ export declare function slotlockPlanReadRefusal(error: unknown, signature?: string): unknown;
package/plans.js ADDED
@@ -0,0 +1,115 @@
1
+ export const SLOTLOCK_PLAN_LIMITS = Object.freeze({
2
+ free: Object.freeze({ resources: 10, apiKeys: 10, webhookEndpoints: 2 }),
3
+ developer: Object.freeze({ resources: 100, apiKeys: 100, webhookEndpoints: 10 }),
4
+ team: Object.freeze({ resources: 500, apiKeys: 100, webhookEndpoints: 25 }),
5
+ });
6
+ export const SLOTLOCK_PLAN_QUOTAS = Object.freeze({
7
+ free: Object.freeze({ operations: 25_000, bookings: 1_000, stopPercent: 100 }),
8
+ developer: Object.freeze({ operations: 500_000, bookings: 25_000, stopPercent: 110 }),
9
+ team: Object.freeze({ operations: 2_000_000, bookings: 100_000, stopPercent: 110 }),
10
+ });
11
+ /**
12
+ * The monthly quotas the tenant is held to (quotas.ts): its own (a partner's account, on its
13
+ * contract plan's numbers, RFC 0003 P4), else its plan's, else null (no plan, or one with none).
14
+ */
15
+ export async function readSlotlockTenantQuotas(sql, tenantRef,
16
+ /**
17
+ * Metered overage (SLOTLOCK_OVERAGE=on, overage.ts): a paying Developer tenant is refused only past
18
+ * this percent of its quota, and what it uses above the quota is billed.
19
+ */
20
+ options = {}) {
21
+ const [row] = await sql `SELECT quotas.plan, quotas.operations::text, quotas.bookings::text, quotas.stop_percent,
22
+ (SELECT given.stripe_subscription IS NOT NULL AND given.status <> 'canceled'
23
+ FROM slotlock.tenant_plan(${tenantRef}::text) given) AS paid
24
+ FROM slotlock.tenant_plan_quotas(${tenantRef}::text) quotas`;
25
+ if (!row)
26
+ return null;
27
+ if (row.operations !== null && row.bookings !== null && row.stop_percent !== null) {
28
+ return { operations: Number(row.operations), bookings: Number(row.bookings), stopPercent: row.stop_percent };
29
+ }
30
+ if (!Object.hasOwn(SLOTLOCK_PLAN_QUOTAS, row.plan))
31
+ return null;
32
+ const quotas = SLOTLOCK_PLAN_QUOTAS[row.plan];
33
+ if (options.overageStopPercent !== undefined && row.plan === 'developer' && row.paid === true) {
34
+ return { ...quotas, stopPercent: Math.max(quotas.stopPercent, options.overageStopPercent) };
35
+ }
36
+ return quotas;
37
+ }
38
+ /** The tenant's plan, or null when it has none (no plan limits apply). */
39
+ export async function readSlotlockTenantPlan(sql, tenantRef) {
40
+ const [row] = await sql `SELECT * FROM slotlock.tenant_plan(${tenantRef}::text)`;
41
+ if (!row)
42
+ return null;
43
+ return {
44
+ plan: row.plan,
45
+ status: row.status,
46
+ limits: {
47
+ resources: row.max_resources,
48
+ apiKeys: row.max_api_keys,
49
+ webhookEndpoints: row.max_webhook_endpoints,
50
+ },
51
+ stripeCustomer: row.stripe_customer,
52
+ stripeSubscription: row.stripe_subscription,
53
+ currentPeriodEnd: row.current_period_end,
54
+ };
55
+ }
56
+ /** Sets the tenant's plan, with the limits that plan allows. */
57
+ export async function setSlotlockTenantPlan(sql, input) {
58
+ const limits = SLOTLOCK_PLAN_LIMITS[input.plan];
59
+ await sql `SELECT slotlock.set_tenant_plan(
60
+ ${input.tenantRef}::text, ${input.plan}::text, ${input.status}::text,
61
+ ${limits.resources}::integer, ${limits.apiKeys}::integer, ${limits.webhookEndpoints}::integer,
62
+ ${input.stripeCustomer ?? null}::text, ${input.stripeSubscription ?? null}::text,
63
+ ${input.currentPeriodEnd ?? null}::timestamptz)`;
64
+ }
65
+ /** The deployment's default plan, or null when it has none. */
66
+ export async function readSlotlockPlanDefault(sql) {
67
+ const [row] = await sql `SELECT * FROM slotlock.plan_default()`;
68
+ if (!row)
69
+ return null;
70
+ return {
71
+ plan: row.plan,
72
+ limits: {
73
+ resources: row.max_resources,
74
+ apiKeys: row.max_api_keys,
75
+ webhookEndpoints: row.max_webhook_endpoints,
76
+ },
77
+ appliesFrom: row.applies_from,
78
+ };
79
+ }
80
+ /**
81
+ * Sets the default plan, with the limits that plan allows, or removes it (null). Only the deployment
82
+ * role may: another is refused EXECUTE (`plan_default_not_permitted`).
83
+ */
84
+ export async function setSlotlockPlanDefault(sql, input) {
85
+ const limits = input ? SLOTLOCK_PLAN_LIMITS[input.plan] : null;
86
+ await sql `SELECT slotlock.set_plan_default(
87
+ ${input?.plan ?? null}::text, ${limits?.resources ?? null}::integer,
88
+ ${limits?.apiKeys ?? null}::integer, ${limits?.webhookEndpoints ?? null}::integer,
89
+ ${input?.appliesFrom ?? null}::timestamptz)`.catch((error) => {
90
+ if (error?.code !== '42501')
91
+ throw error;
92
+ throw Object.assign(new Error('Slotlock sets the default plan only as the deployment role', { cause: error }), { code: 'plan_default_not_permitted' });
93
+ });
94
+ }
95
+ /**
96
+ * Puts a new tenant on the default plan at once, while the default does not yet apply to every
97
+ * tenant: one with no API key, nothing in its feed and no webhook. True when it did. The dashboard
98
+ * calls it before a tenant adds a key, resource or webhook endpoint.
99
+ */
100
+ export async function enrolSlotlockTenantPlan(sql, tenantRef) {
101
+ const [row] = await sql `
102
+ SELECT slotlock.enrol_tenant_plan(${tenantRef}::text) AS enrolled`.catch((error) => {
103
+ throw slotlockPlanReadRefusal(error, 'slotlock.enrol_tenant_plan(text)');
104
+ });
105
+ return row?.enrolled === true;
106
+ }
107
+ /**
108
+ * Reading a tenant's plan needs EXECUTE on slotlock.tenant_plan(text), which `slotlock migrate` (or
109
+ * grantApplicationRole) grants: a role without it is told so (`application_role_not_granted`).
110
+ */
111
+ export function slotlockPlanReadRefusal(error, signature = 'slotlock.tenant_plan(text)') {
112
+ if (error?.code !== '42501')
113
+ return error;
114
+ return Object.assign(new Error(`Slotlock serving role may not run ${signature}: grant it what this release needs with grantApplicationRole (\`slotlock migrate\` does), once per release`, { cause: error }), { code: 'application_role_not_granted' });
115
+ }