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
@@ -0,0 +1,333 @@
1
+ // Closing a partner's month (RFC 0003 P5, P6): what its accounts used, the invoice lines its
2
+ // contract prices that at, recorded once, the signed usage.period_closed webhook that sends the
3
+ // partner the same numbers first, and the lines added to its next Stripe invoice.
4
+ //
5
+ // - Lines are recorded in slotlock.partner_invoices before anything is sent, and a month's lines
6
+ // are never priced again: closing a month twice (a retry days later) sends the same numbers, and
7
+ // only the lines Stripe has not taken yet. Stripe's own idempotency keys last about a day, so they
8
+ // only guard a retry within one run.
9
+ // - The webhook carries the month's totals by account and metric, the lines, and a SHA-256 of the
10
+ // canonical rows, so both sides can check they hold the same numbers; `GET /v1/partner/usage`
11
+ // reads the same counts.
12
+ // - Pylota is the merchant here (a partner is billed as an ordinary business customer), not
13
+ // Managed Payments, which sells only directly to customers.
14
+ import { createHash } from 'node:crypto';
15
+ import { SLOTLOCK_STRIPE_API_VERSION } from './billing.js';
16
+ import { parseSlotlockPartnerContract } from './partners.js';
17
+ import { createSlotlockWebhookSender, signSlotlockWebhook } from './webhooks.js';
18
+ /**
19
+ * Price a month with the contract (P6): each account with at least one operation, each 10,000
20
+ * operations and each 100 bookings an account made above its plan's quota (each account rounded up
21
+ * on its own), and the minimum's shortfall. No pricing in the contract: no lines.
22
+ */
23
+ export function priceSlotlockPartnerMonth(contract, accounts) {
24
+ const pricing = contract.pricing;
25
+ if (!pricing)
26
+ return [];
27
+ const lines = [];
28
+ const active = accounts.filter((month) => (month.totals.operations ?? 0) > 0).length;
29
+ if (active > 0 && pricing.activeAccount > 0) {
30
+ lines.push({
31
+ line: 'active_accounts',
32
+ description: `Active accounts (${active} × ${pricing.activeAccount} ${pricing.currency} minor units)`,
33
+ quantity: active,
34
+ amount: active * pricing.activeAccount,
35
+ currency: pricing.currency,
36
+ });
37
+ }
38
+ const over = (metric, per) => accounts.reduce((sum, month) => {
39
+ const plan = contract.plans[month.plan];
40
+ const quota = plan ? plan[metric] : Number.POSITIVE_INFINITY;
41
+ return sum + Math.ceil(Math.max(0, (month.totals[metric] ?? 0) - quota) / per);
42
+ }, 0);
43
+ const operationBlocks = over('operations', 10_000);
44
+ if (operationBlocks > 0 && pricing.operationsOverQuotaPer10k > 0) {
45
+ lines.push({
46
+ line: 'operations_over_quota',
47
+ description: `Operations above the accounts' quotas (${operationBlocks} × 10,000)`,
48
+ quantity: operationBlocks,
49
+ amount: operationBlocks * pricing.operationsOverQuotaPer10k,
50
+ currency: pricing.currency,
51
+ });
52
+ }
53
+ const bookingBlocks = over('bookings', 100);
54
+ if (bookingBlocks > 0 && pricing.bookingsOverQuotaPer100 > 0) {
55
+ lines.push({
56
+ line: 'bookings_over_quota',
57
+ description: `Bookings above the accounts' quotas (${bookingBlocks} × 100)`,
58
+ quantity: bookingBlocks,
59
+ amount: bookingBlocks * pricing.bookingsOverQuotaPer100,
60
+ currency: pricing.currency,
61
+ });
62
+ }
63
+ const subtotal = lines.reduce((sum, line) => sum + line.amount, 0);
64
+ if (subtotal < pricing.minimum) {
65
+ lines.push({
66
+ line: 'minimum',
67
+ description: 'Up to the monthly minimum',
68
+ quantity: 1,
69
+ amount: pricing.minimum - subtotal,
70
+ currency: pricing.currency,
71
+ });
72
+ }
73
+ return lines;
74
+ }
75
+ /** The SHA-256 (hex) of a month's canonical rows: `account\tmetric\tcount` lines, sorted, then the invoice lines. */
76
+ export function slotlockPartnerMonthChecksum(accounts, lines) {
77
+ const usage = accounts
78
+ .flatMap((month) => Object.entries(month.totals).map(([metric, count]) => `${month.account}\t${metric}\t${count}`))
79
+ .sort();
80
+ const priced = lines.map((line) => `${line.line}\t${line.quantity}\t${line.amount}\t${line.currency}`).sort();
81
+ return createHash('sha256').update([...usage, '', ...priced].join('\n')).digest('hex');
82
+ }
83
+ /** `YYYY-MM`, a month that has ended by `now` (UTC): its first day and the next month's. */
84
+ export function slotlockClosedMonth(month, now) {
85
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) {
86
+ throw Object.assign(new Error('Slotlock month must be YYYY-MM'), { code: 'invalid_month' });
87
+ }
88
+ const [year, number] = month.split('-').map(Number);
89
+ const start = Date.UTC(year, number - 1, 1);
90
+ const next = Date.UTC(year, number, 1);
91
+ if (next > now) {
92
+ throw Object.assign(new Error(`Slotlock month ${month} has not ended`), { code: 'month_not_ended' });
93
+ }
94
+ const day = (at) => new Date(at).toISOString().slice(0, 10);
95
+ return { from: day(start), to: day(next - 86_400_000), next: day(next) };
96
+ }
97
+ /** The month before `now` (UTC), `YYYY-MM`: what a run on the 1st closes. */
98
+ export function slotlockPreviousMonth(now) {
99
+ const date = new Date(now);
100
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1)).toISOString().slice(0, 7);
101
+ }
102
+ /** Each account's month: what its tenant used, and what an erased one of the same name had used. */
103
+ async function readPartnerMonth(sql, partnerId, range) {
104
+ const rows = await sql `
105
+ WITH daily AS (
106
+ SELECT tenant_ref, day, metric, sum(count) AS count
107
+ FROM slotlock.usage_daily
108
+ WHERE day BETWEEN ${range.from}::date AND ${range.to}::date
109
+ AND tenant_ref IN (SELECT tenant_ref FROM slotlock.partner_accounts WHERE partner_id = ${partnerId})
110
+ GROUP BY tenant_ref, day, metric
111
+ )
112
+ SELECT owned.account, owned.plan, used.metric,
113
+ CASE WHEN used.metric = 'resources' THEN max(used.count) ELSE sum(used.count) END::text AS count
114
+ FROM slotlock.partner_accounts owned
115
+ LEFT JOIN daily used ON used.tenant_ref = owned.tenant_ref
116
+ WHERE owned.partner_id = ${partnerId}
117
+ GROUP BY owned.account, owned.plan, used.metric
118
+ UNION ALL
119
+ SELECT erased.account, erased.plan, erased.metric, erased.count::text
120
+ FROM slotlock.partner_erased_usage erased
121
+ WHERE erased.partner_id = ${partnerId} AND erased.month = ${range.from}::date`;
122
+ const byAccount = new Map();
123
+ for (const row of rows) {
124
+ const month = byAccount.get(row.account) ?? { account: row.account, plan: row.plan, totals: {} };
125
+ if (row.metric !== null) {
126
+ const totals = month.totals;
127
+ const count = Number(row.count ?? 0);
128
+ // An account erased and made again in the month: both counted, resources at their most.
129
+ totals[row.metric] = row.metric === 'resources' ? Math.max(totals[row.metric] ?? 0, count) : (totals[row.metric] ?? 0) + count;
130
+ }
131
+ byAccount.set(row.account, month);
132
+ }
133
+ return canonicalAccounts([...byAccount.values()]);
134
+ }
135
+ /** Accounts by name, each one's metrics by name: the order the webhook body and its checksum use. */
136
+ function canonicalAccounts(accounts) {
137
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
138
+ return [...accounts]
139
+ .sort((a, b) => byName(a.account, b.account))
140
+ .map((month) => ({
141
+ account: month.account,
142
+ plan: month.plan,
143
+ totals: Object.fromEntries(Object.entries(month.totals).sort(([a], [b]) => byName(a, b))),
144
+ }));
145
+ }
146
+ /** Stripe's invoice items for the customer that this partner's month already made (a run that stopped after Stripe took them). */
147
+ async function existingInvoiceItems(stripe, customer, partnerId, month) {
148
+ const send = stripe.fetch ?? fetch;
149
+ const found = new Map();
150
+ let after;
151
+ for (let page = 0; page < 10; page += 1) {
152
+ const query = new URLSearchParams({ customer, limit: '100', ...(after ? { starting_after: after } : {}) });
153
+ const response = await send(`${stripe.apiBase ?? 'https://api.stripe.com'}/v1/invoiceitems?${query.toString()}`, {
154
+ headers: { authorization: `Bearer ${stripe.secretKey}`, 'stripe-version': SLOTLOCK_STRIPE_API_VERSION },
155
+ }).catch(() => null);
156
+ if (!response?.ok)
157
+ throw Object.assign(new Error('Stripe did not list the invoice items'), { code: 'stripe_unavailable' });
158
+ const listed = (await response.json().catch(() => null));
159
+ for (const item of listed?.data ?? []) {
160
+ if (typeof item.id === 'string' &&
161
+ item.metadata?.slotlock_partner === partnerId &&
162
+ item.metadata.slotlock_month === month &&
163
+ typeof item.metadata.slotlock_line === 'string') {
164
+ found.set(item.metadata.slotlock_line, item.id);
165
+ }
166
+ }
167
+ const last = listed?.data?.at(-1)?.id;
168
+ if (!listed?.has_more || typeof last !== 'string')
169
+ break;
170
+ after = last;
171
+ }
172
+ return found;
173
+ }
174
+ /**
175
+ * Close one partner's month, as the schema owner (`slotlock partner close`). The first run freezes
176
+ * it: each account's totals (erased accounts' included), the invoice lines the contract prices them
177
+ * at and their checksum, in one transaction. Every run then sends usage.period_closed with that same
178
+ * body until the partner's endpoint takes it (a 2xx), and adds to the partner's next Stripe invoice
179
+ * each line Stripe has not taken, adopting one it took in a run that stopped before recording it.
180
+ * Nothing is priced or billed twice, however often or concurrently it runs.
181
+ */
182
+ export async function closeSlotlockPartnerMonth(sql, input) {
183
+ const now = input.now ?? Date.now();
184
+ const range = slotlockClosedMonth(input.month, now);
185
+ const [partner] = await sql `SELECT contract, stripe_customer, webhook_url, webhook_secret FROM slotlock.partners WHERE id = ${input.partnerId}`;
186
+ if (!partner)
187
+ throw Object.assign(new Error('Slotlock partner not found'), { code: 'partner_not_found' });
188
+ // Frozen on the first run, under the partner's month lock, so racing runs freeze it once.
189
+ let closedNow = false;
190
+ const frozen = await sql.begin(async (tx) => {
191
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:partner-month:${input.partnerId}:${input.month}`}, 0))`;
192
+ const [kept] = await tx `
193
+ SELECT accounts, checksum, webhook_delivered_at IS NOT NULL AS delivered
194
+ FROM slotlock.partner_month_closes WHERE partner_id = ${input.partnerId} AND month = ${range.from}::date`;
195
+ // jsonb keeps no key order: put it back, so every run sends the same bytes.
196
+ if (kept)
197
+ return { accounts: canonicalAccounts(kept.accounts), checksum: kept.checksum, webhookDelivered: kept.delivered };
198
+ const accounts = await readPartnerMonth(tx, input.partnerId, range);
199
+ const priced = priceSlotlockPartnerMonth(parseSlotlockPartnerContract(partner.contract), accounts);
200
+ for (const line of priced) {
201
+ await tx `
202
+ INSERT INTO slotlock.partner_invoices (partner_id, month, line, description, quantity, amount, currency)
203
+ VALUES (${input.partnerId}, ${range.from}::date, ${line.line}, ${line.description}, ${line.quantity},
204
+ ${line.amount}, ${line.currency})`;
205
+ }
206
+ const checksum = slotlockPartnerMonthChecksum(accounts, priced);
207
+ await tx `
208
+ INSERT INTO slotlock.partner_month_closes (partner_id, month, accounts, checksum)
209
+ VALUES (${input.partnerId}, ${range.from}::date, ${JSON.stringify(accounts)}::text::jsonb, ${checksum})`;
210
+ closedNow = true;
211
+ return { accounts, checksum, webhookDelivered: false };
212
+ });
213
+ const recorded = await sql `SELECT line, description, quantity::text, amount::text, currency, stripe_item
214
+ FROM slotlock.partner_invoices
215
+ WHERE partner_id = ${input.partnerId} AND month = ${range.from}::date
216
+ ORDER BY line`;
217
+ const lines = recorded.map((row) => ({
218
+ line: row.line,
219
+ description: row.description,
220
+ quantity: Number(row.quantity),
221
+ amount: Number(row.amount),
222
+ currency: row.currency,
223
+ stripeItem: row.stripe_item,
224
+ }));
225
+ // The partner hears the numbers first (P5), signed as any Slotlock webhook, the same body each time.
226
+ let webhook = 'no_webhook';
227
+ let webhookStatus;
228
+ if (frozen.webhookDelivered) {
229
+ webhook = 'already_delivered';
230
+ }
231
+ else if (partner.webhook_url && partner.webhook_secret) {
232
+ const id = `usage_period_closed_${input.partnerId}_${input.month.replace('-', '_')}`;
233
+ const timestamp = Math.floor(now / 1_000);
234
+ const body = JSON.stringify({
235
+ type: 'usage.period_closed',
236
+ partner: input.partnerId,
237
+ month: input.month,
238
+ from: range.from,
239
+ to: range.to,
240
+ accounts: frozen.accounts.map((month) => ({ account: month.account, plan: month.plan, totals: month.totals })),
241
+ lines: lines.map(({ stripeItem: _, ...line }) => line),
242
+ checksum: frozen.checksum,
243
+ });
244
+ try {
245
+ const answer = await (input.send ?? createSlotlockWebhookSender())({
246
+ url: partner.webhook_url,
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'webhook-id': id,
250
+ 'webhook-timestamp': String(timestamp),
251
+ 'webhook-signature': signSlotlockWebhook({ secret: partner.webhook_secret, id, timestamp, body }),
252
+ },
253
+ body,
254
+ });
255
+ webhookStatus = answer.status;
256
+ webhook = answer.status >= 200 && answer.status < 300 ? 'delivered' : 'failed';
257
+ }
258
+ catch {
259
+ webhook = 'failed';
260
+ }
261
+ await sql `
262
+ UPDATE slotlock.partner_month_closes
263
+ SET webhook_status = ${webhookStatus ?? null},
264
+ webhook_delivered_at = CASE WHEN ${webhook === 'delivered'} THEN statement_timestamp() ELSE webhook_delivered_at END
265
+ WHERE partner_id = ${input.partnerId} AND month = ${range.from}::date`;
266
+ }
267
+ // Then Stripe (P6): each line it has not taken, once, on the partner's next invoice.
268
+ let invoiced = 0;
269
+ const waiting = lines.filter((line) => line.stripeItem === null && line.amount > 0);
270
+ if (input.stripe && partner.stripe_customer && waiting.length > 0) {
271
+ const send = input.stripe.fetch ?? fetch;
272
+ // An item a run made before it stopped is adopted, never made again (idempotency keys last a day).
273
+ const made = await existingInvoiceItems(input.stripe, partner.stripe_customer, input.partnerId, input.month);
274
+ for (const line of waiting) {
275
+ let item = made.get(line.line);
276
+ if (item === undefined) {
277
+ const form = new URLSearchParams({
278
+ customer: partner.stripe_customer,
279
+ amount: String(line.amount),
280
+ currency: line.currency,
281
+ description: `${input.month}: ${line.description}`,
282
+ 'metadata[slotlock_partner]': input.partnerId,
283
+ 'metadata[slotlock_month]': input.month,
284
+ 'metadata[slotlock_line]': line.line,
285
+ });
286
+ const response = await send(`${input.stripe.apiBase ?? 'https://api.stripe.com'}/v1/invoiceitems`, {
287
+ method: 'POST',
288
+ headers: {
289
+ authorization: `Bearer ${input.stripe.secretKey}`,
290
+ 'content-type': 'application/x-www-form-urlencoded',
291
+ 'stripe-version': SLOTLOCK_STRIPE_API_VERSION,
292
+ 'idempotency-key': `slotlock-partner-${input.partnerId}-${input.month}-${line.line}`,
293
+ },
294
+ body: form.toString(),
295
+ }).catch(() => null);
296
+ if (!response?.ok)
297
+ continue;
298
+ item = (await response.json().catch(() => ({}))).id;
299
+ }
300
+ if (typeof item !== 'string' || !/^ii_[A-Za-z0-9]{1,250}$/.test(item))
301
+ continue;
302
+ await sql `
303
+ UPDATE slotlock.partner_invoices SET stripe_item = ${item}, sent_at = now()
304
+ WHERE partner_id = ${input.partnerId} AND month = ${range.from}::date AND line = ${line.line}
305
+ AND stripe_item IS NULL`;
306
+ line.stripeItem = item;
307
+ invoiced += 1;
308
+ }
309
+ }
310
+ return {
311
+ partner: input.partnerId,
312
+ month: input.month,
313
+ accounts: frozen.accounts,
314
+ lines,
315
+ checksum: frozen.checksum,
316
+ closedNow,
317
+ webhook,
318
+ ...(webhookStatus !== undefined ? { webhookStatus } : {}),
319
+ invoiced,
320
+ pending: lines.filter((line) => line.stripeItem === null && line.amount > 0).length,
321
+ };
322
+ }
323
+ /**
324
+ * The `Slotlock-Client` request field (P5), as metrics count it: the name before any `/version`,
325
+ * when it is one of the deployment's known integrators (SLOTLOCK_KNOWN_CLIENTS), else `other`, and
326
+ * `none` without the field. Never anything else from the request.
327
+ */
328
+ export function slotlockClientLabel(field, known) {
329
+ if (field === null || field.trim() === '')
330
+ return 'none';
331
+ const name = field.trim().split('/')[0]?.toLowerCase() ?? '';
332
+ return known.has(name) ? name : 'other';
333
+ }
@@ -0,0 +1,8 @@
1
+ /** The scope a partner key carries, and the partner API needs. */
2
+ export declare const SLOTLOCK_PARTNER_SCOPE = "partner";
3
+ /** A partner's id: lowercase, 2-40 characters, a letter first. */
4
+ export declare const SLOTLOCK_PARTNER_ID: RegExp;
5
+ /** A partner's id for one of its users: its own, 1-128 letters, digits, dots, dashes or underscores. */
6
+ export declare const SLOTLOCK_PARTNER_ACCOUNT: RegExp;
7
+ /** A contract plan's name. */
8
+ export declare const SLOTLOCK_PARTNER_PLAN: RegExp;
@@ -0,0 +1,10 @@
1
+ // The names partner accounts use (RFC 0003 part 4), with no imports, so the operation schemas
2
+ // (application-api.ts) and the partner store (partners.ts) share them without a cycle.
3
+ /** The scope a partner key carries, and the partner API needs. */
4
+ export const SLOTLOCK_PARTNER_SCOPE = 'partner';
5
+ /** A partner's id: lowercase, 2-40 characters, a letter first. */
6
+ export const SLOTLOCK_PARTNER_ID = /^[a-z][a-z0-9-]{1,39}$/;
7
+ /** A partner's id for one of its users: its own, 1-128 letters, digits, dots, dashes or underscores. */
8
+ export const SLOTLOCK_PARTNER_ACCOUNT = /^[A-Za-z0-9._-]{1,128}$/;
9
+ /** A contract plan's name. */
10
+ export const SLOTLOCK_PARTNER_PLAN = /^[a-z][a-z0-9_]{0,40}$/;
package/partners.d.ts ADDED
@@ -0,0 +1,147 @@
1
+ import type { Sql } from 'postgres';
2
+ import { type SlotlockAgentPrincipal } from './agent-server.js';
3
+ import { type SlotlockApiKey, type SlotlockApiKeyScope, type SlotlockApiKeyStore } from './api-keys.js';
4
+ import type { SlotlockSql } from './store.js';
5
+ import { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_PLAN, SLOTLOCK_PARTNER_SCOPE } from './partner-names.js';
6
+ export { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_ID, SLOTLOCK_PARTNER_PLAN, SLOTLOCK_PARTNER_SCOPE };
7
+ /** The tenant a partner's account is. */
8
+ export declare function slotlockPartnerTenantRef(partnerId: string, account: string): string;
9
+ /** What one contract plan allows an account: as a hosted plan's limits and quotas. */
10
+ export interface SlotlockPartnerPlan {
11
+ resources: number;
12
+ apiKeys: number;
13
+ webhookEndpoints: number;
14
+ operations: number;
15
+ bookings: number;
16
+ /** Where use is refused, in percent of the quota (100-1000). */
17
+ stopPercent: number;
18
+ }
19
+ /**
20
+ * What a partner is billed (P6), in the currency's minor units: each account with at least one
21
+ * operation in the month, each 10,000 operations and each 100 bookings an account made above its
22
+ * plan's quota, and at least `minimum` a month.
23
+ */
24
+ export interface SlotlockPartnerPricing {
25
+ currency: string;
26
+ activeAccount: number;
27
+ operationsOverQuotaPer10k: number;
28
+ bookingsOverQuotaPer100: number;
29
+ minimum: number;
30
+ }
31
+ export interface SlotlockPartnerContract {
32
+ plans: Readonly<Record<string, SlotlockPartnerPlan>>;
33
+ /** The scopes an account's key may carry; `read` and `write` unless the contract says. */
34
+ keyScopes: readonly SlotlockApiKeyScope[];
35
+ pricing?: SlotlockPartnerPricing;
36
+ /**
37
+ * Whether the partner's keys may act for its accounts with `Slotlock-Account` (`act_on_behalf`,
38
+ * RFC 0003 P3), instead of a key per account. Off unless the contract says.
39
+ */
40
+ actOnBehalf?: boolean;
41
+ }
42
+ /**
43
+ * Read a contract as the owner writes it (snake_case JSON, as the database keeps it), refusing one
44
+ * the database would not take: `invalid_partner_contract`.
45
+ */
46
+ export declare function parseSlotlockPartnerContract(value: unknown): SlotlockPartnerContract;
47
+ /** One of the partner's accounts, as the partner API shows it. */
48
+ export interface SlotlockPartnerAccount {
49
+ account: string;
50
+ plan: string;
51
+ status: 'active' | 'suspended';
52
+ createdAt: Date;
53
+ updatedAt: Date;
54
+ /** Resources, archived ones included, and active keys: on a single account's read only. */
55
+ resources?: number;
56
+ apiKeys?: number;
57
+ }
58
+ /** A partner's accounts' usage by account, UTC day and metric (P5). */
59
+ export interface SlotlockPartnerUsageRow {
60
+ account: string;
61
+ day: string;
62
+ metric: string;
63
+ count: number;
64
+ }
65
+ /** The partner a principal acts for: a partner key's, or nothing (never a tenant's key). */
66
+ export declare function slotlockPrincipalPartner(principal: SlotlockAgentPrincipal): string;
67
+ /** What the partner API reads and changes, for one partner at a time. */
68
+ export interface SlotlockPartnerStore {
69
+ contract(partnerId: string): Promise<SlotlockPartnerContract | null>;
70
+ putAccount(partnerId: string, account: string, plan: string): Promise<{
71
+ account: SlotlockPartnerAccount;
72
+ created: boolean;
73
+ } | null>;
74
+ listAccounts(partnerId: string): Promise<SlotlockPartnerAccount[]>;
75
+ getAccount(partnerId: string, account: string): Promise<SlotlockPartnerAccount | null>;
76
+ /** The account's tenant, or null when the partner has no such account. */
77
+ tenantOf(partnerId: string, account: string): Promise<string | null>;
78
+ /** True when it changed something; null for no such account. */
79
+ setSuspended(partnerId: string, account: string, suspended: boolean, reason?: string): Promise<boolean | null>;
80
+ usage(partnerId: string, from: string, to: string, account?: string): Promise<SlotlockPartnerUsageRow[]>;
81
+ /** Record a partner's comparison of one day's counts (P5 reconciliation). */
82
+ recordReconciliation(partnerId: string, day: string, accounts: number, mismatched: number, worstPercent: number): Promise<void>;
83
+ /** The account's keys, made and changed as any tenant's (api-keys.ts). */
84
+ keys: SlotlockApiKeyStore;
85
+ /** Erase the account's tenant, with its partner's record of it. */
86
+ eraseAccount(partnerId: string, account: string): Promise<{
87
+ erased: boolean;
88
+ total: number;
89
+ }>;
90
+ }
91
+ /**
92
+ * The partner store over the serving role, through SLOTLOCK_PARTNER_FUNCTIONS. `keys` is the API
93
+ * key store; `eraseTenant` the store's erasure (a tenant's whole data, as `/v1/tenant/erase`).
94
+ */
95
+ export declare function createSlotlockPartnerStore(sql: SlotlockSql, deps: {
96
+ keys: SlotlockApiKeyStore;
97
+ eraseTenant(tenantRef: string): Promise<{
98
+ total: number;
99
+ }>;
100
+ }): SlotlockPartnerStore;
101
+ /**
102
+ * Bearer authentication by partner key: a request with an active partner's unrevoked key is the
103
+ * principal `partner_key:<id>` with the `partner` scope and its `partnerId`, in a tenant of its own
104
+ * (`partner:<partner>`) that holds no data. Anything else is not a partner key (null), for the
105
+ * tenant authenticator to try.
106
+ */
107
+ export declare function createSlotlockPartnerKeyAuthenticator(sql: SlotlockSql): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
108
+ /** The request field a partner key names one of its accounts in, to act for it (RFC 0003 P3). */
109
+ export declare const SLOTLOCK_ACCOUNT_FIELD = "slotlock-account";
110
+ export interface SlotlockPartner {
111
+ id: string;
112
+ name: string;
113
+ status: 'active' | 'suspended';
114
+ contract: SlotlockPartnerContract;
115
+ stripeCustomer: string | null;
116
+ webhookUrl: string | null;
117
+ createdAt: Date;
118
+ }
119
+ /** The contract as the database keeps it: snake_case, as the owner writes it. */
120
+ export declare function slotlockPartnerContractJson(contract: SlotlockPartnerContract): Record<string, unknown>;
121
+ /** Create a partner, or change its name and contract (as the schema owner). */
122
+ export declare function putSlotlockPartner(sql: Sql, input: {
123
+ id: string;
124
+ name: string;
125
+ contract: unknown;
126
+ }): Promise<SlotlockPartner>;
127
+ export declare function listSlotlockPartners(sql: Sql): Promise<SlotlockPartner[]>;
128
+ /** Suspend a partner (every account with it) or make it active again. False for no such partner. */
129
+ export declare function setSlotlockPartnerStatus(sql: Sql, id: string, status: 'active' | 'suspended'): Promise<boolean>;
130
+ /** Where the partner is billed (P6) and where usage.period_closed goes (P5), with its secret. */
131
+ export declare function setSlotlockPartnerBilling(sql: Sql, id: string, input: {
132
+ stripeCustomer?: string | null;
133
+ webhookUrl?: string | null;
134
+ webhookSecret?: string | null;
135
+ }): Promise<boolean>;
136
+ /** A key for the partner, shown once: only its SHA-256 is kept. */
137
+ export declare function createSlotlockPartnerKey(sql: Sql, partnerId: string, name: string): Promise<{
138
+ key: string;
139
+ id: string;
140
+ prefix: string;
141
+ }>;
142
+ /** Revoke one of a partner's keys at once. False for no such key. */
143
+ export declare function revokeSlotlockPartnerKey(sql: Sql, partnerId: string, id: string): Promise<boolean>;
144
+ /** The partner API's view of a key it made for one of its accounts. */
145
+ export declare function slotlockPartnerKeyOutput(apiKey: SlotlockApiKey): Record<string, unknown>;
146
+ /** The longest usage range the partner API reads, in days. */
147
+ export declare const SLOTLOCK_PARTNER_USAGE_DAYS = 400;