slotlock 0.6.3 → 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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  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 +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
package/quotas.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { type SlotlockAgentOperation, SlotlockAgentOperationError } from './agent-server.js';
2
+ import type { SlotlockApplicationOperation } from './application-api.js';
3
+ import { SLOTLOCK_QUOTA_METRICS, SLOTLOCK_QUOTA_NOTICE_PERCENTS } from './ddl.js';
4
+ import type { SlotlockPlanQuotas } from './plans.js';
5
+ import type { SlotlockSql } from './store.js';
6
+ import type { SlotlockRecordedUsageMetric } from './usage.js';
7
+ export { SLOTLOCK_QUOTA_METRICS, SLOTLOCK_QUOTA_NOTICE_PERCENTS };
8
+ export type SlotlockQuotaMetric = (typeof SLOTLOCK_QUOTA_METRICS)[number];
9
+ /**
10
+ * The operations that can make a booking (a reservation, an event, a confirmed hold) or hold a slot
11
+ * for one: refused once the bookings quota is used up. Everything else, reads and cancellations
12
+ * included, runs until the operations quota is.
13
+ */
14
+ export declare const SLOTLOCK_BOOKING_OPERATIONS: readonly (SlotlockAgentOperation | SlotlockApplicationOperation)[];
15
+ /**
16
+ * Operations a quota never refuses, though each still counts: reading the month's usage, so a
17
+ * tenant can always see where it stands, and erasing the tenant, which no allowance may hold up.
18
+ */
19
+ export declare const SLOTLOCK_QUOTA_EXEMPT_OPERATIONS: readonly SlotlockApplicationOperation[];
20
+ /** A tenant's operations and bookings so far this month, as the database holds them. */
21
+ export interface SlotlockQuotaUse {
22
+ operations: number;
23
+ bookings: number;
24
+ }
25
+ /** One quota of a tenant this month. */
26
+ export interface SlotlockQuotaStanding {
27
+ metric: SlotlockQuotaMetric;
28
+ /** The plan's quota. */
29
+ quota: number;
30
+ /** Where use is refused: the quota, or past it on a plan that allows a margin (Developer 110%). */
31
+ stop: number;
32
+ used: number;
33
+ /** The month's first day, `YYYY-MM-DD` (UTC). */
34
+ periodStart: string;
35
+ /** When the month ends and the counts start again (the next month's first instant, UTC). */
36
+ resetsAt: Date;
37
+ }
38
+ export type SlotlockQuotaCheck =
39
+ /** `standings` is empty for a tenant with no plan, which no quota binds. */
40
+ {
41
+ ok: true;
42
+ standings: SlotlockQuotaStanding[];
43
+ } | {
44
+ ok: false;
45
+ exceeded: SlotlockQuotaStanding;
46
+ standings: SlotlockQuotaStanding[];
47
+ };
48
+ /** A share of a quota the tenant reached, to note once (slotlock.note_usage_threshold). */
49
+ export interface SlotlockQuotaNotice {
50
+ tenantRef: string;
51
+ metric: SlotlockQuotaMetric;
52
+ percent: (typeof SLOTLOCK_QUOTA_NOTICE_PERCENTS)[number];
53
+ used: number;
54
+ quota: number;
55
+ /** The month's first day, `YYYY-MM-DD`. */
56
+ periodStart: string;
57
+ }
58
+ export interface SlotlockQuotasOptions {
59
+ /**
60
+ * The quotas the tenant's plan gives it (plans.ts readSlotlockTenantQuotas: a partner account's
61
+ * own, else its plan's), or null when it has none: then no quota binds it.
62
+ */
63
+ plan(tenantRef: string): Promise<SlotlockPlanQuotas | null>;
64
+ /** The tenant's operations and bookings from `fromDay` (`YYYY-MM-DD`) until now. */
65
+ used(tenantRef: string, fromDay: string): Promise<SlotlockQuotaUse>;
66
+ /**
67
+ * What this server counted and has not recorded yet (the usage meter's `local`). Without it, only
68
+ * what the database holds is counted.
69
+ */
70
+ local?(tenantRef: string, metric: SlotlockRecordedUsageMetric, fromDay: string): {
71
+ unrecorded: number;
72
+ counted: number;
73
+ };
74
+ /** Note a share reached; the database sends each once. Not awaited; a failure is tried again. */
75
+ notice?(notice: SlotlockQuotaNotice): Promise<unknown>;
76
+ /** A plan or count that could not be read (the check then lets the operation run), or a notice. */
77
+ onError?(error: unknown): void;
78
+ /** Milliseconds since the epoch, for tests; defaults to `Date.now`. */
79
+ now?: () => number;
80
+ }
81
+ export interface SlotlockQuotas {
82
+ /**
83
+ * Where the tenant stands against its quotas, and whether `operation` may run. Never throws: a
84
+ * plan or count it cannot read lets the operation run, on the last counts it read if any.
85
+ */
86
+ check(args: {
87
+ tenantRef: string;
88
+ operation: SlotlockAgentOperation | SlotlockApplicationOperation;
89
+ }): Promise<SlotlockQuotaCheck>;
90
+ }
91
+ /** The month an instant falls in: its first day and the next month's first instant, UTC. */
92
+ export declare function slotlockQuotaPeriod(at: number): {
93
+ periodStart: string;
94
+ resetsAt: Date;
95
+ };
96
+ export declare function createSlotlockQuotas(options: SlotlockQuotasOptions): SlotlockQuotas;
97
+ /** `slotlock.tenant_quota_usage`: the tenant's operations and bookings from `fromDay` until now. */
98
+ export declare function readSlotlockQuotaUse(sql: SlotlockSql, tenantRef: string, fromDay: string): Promise<SlotlockQuotaUse>;
99
+ /** `slotlock.note_usage_threshold`: true when this was the first notice of that share this month. */
100
+ export declare function noteSlotlockQuotaThreshold(sql: SlotlockSql, notice: SlotlockQuotaNotice): Promise<boolean>;
101
+ /** The refusal of an operation over a quota: `quota_exceeded` (429), naming the quota and its reset. */
102
+ export declare function slotlockQuotaRefusal(exceeded: SlotlockQuotaStanding): SlotlockAgentOperationError;
103
+ /** The answer's fields for a tenant's quotas: RateLimit list members, and Retry-After when refused. */
104
+ export declare function slotlockQuotaFields(standings: readonly SlotlockQuotaStanding[], at: number): {
105
+ rateLimit: string[];
106
+ policy: string[];
107
+ retryAfter: number;
108
+ };
package/quotas.js ADDED
@@ -0,0 +1,258 @@
1
+ // Monthly quotas (RFC 0003 part 3): each hosted plan allows so many operations and bookings a UTC
2
+ // calendar month (plans.ts SLOTLOCK_PLAN_QUOTAS). The server checks each operation against them as
3
+ // it is authorized: past the operations quota every operation is refused, past the bookings quota
4
+ // only those that can make a booking, so a tenant can still read and cancel. A refusal is
5
+ // `quota_exceeded` (429) with Retry-After at the month's end (rate-limit.ts renders it), and at 80%
6
+ // and 100% of each quota the tenant's feed records `usage.threshold_reached` once.
7
+ //
8
+ // What a check counts: the database's month so far (slotlock.tenant_quota_usage: the usage recorded,
9
+ // and the bookings in the feed not yet rolled up), read at most every 30 seconds, and every 2 near
10
+ // the stop; plus this server's operations the database does not hold yet (the usage meter's), and
11
+ // the booking operations it ran since the read. Other servers' operations of the last 15 seconds or
12
+ // so are not seen, so a busy tenant can go a little past its stop; never by more than that.
13
+ // Off unless the deployment asks for it (SLOTLOCK_QUOTAS=on, with SLOTLOCK_USAGE=on).
14
+ import { SlotlockAgentOperationError } from './agent-server.js';
15
+ import { SLOTLOCK_QUOTA_METRICS, SLOTLOCK_QUOTA_NOTICE_PERCENTS } from './ddl.js';
16
+ export { SLOTLOCK_QUOTA_METRICS, SLOTLOCK_QUOTA_NOTICE_PERCENTS };
17
+ /**
18
+ * The operations that can make a booking (a reservation, an event, a confirmed hold) or hold a slot
19
+ * for one: refused once the bookings quota is used up. Everything else, reads and cancellations
20
+ * included, runs until the operations quota is.
21
+ */
22
+ export const SLOTLOCK_BOOKING_OPERATIONS = Object.freeze([
23
+ 'slotlock_hold_slots',
24
+ 'slotlock_book_together',
25
+ 'slotlock_book_pool',
26
+ 'slotlock_create_event',
27
+ 'slotlock_acquire_hold',
28
+ 'slotlock_confirm_hold',
29
+ 'slotlock_put_reservation',
30
+ ]);
31
+ /**
32
+ * Operations a quota never refuses, though each still counts: reading the month's usage, so a
33
+ * tenant can always see where it stands, and erasing the tenant, which no allowance may hold up.
34
+ */
35
+ export const SLOTLOCK_QUOTA_EXEMPT_OPERATIONS = Object.freeze([
36
+ 'slotlock_get_usage',
37
+ 'slotlock_erase_tenant',
38
+ ]);
39
+ /** How long a tenant's plan and counts are trusted, and how long near its stop. */
40
+ const REFRESH_MS = 30_000;
41
+ const NEAR_STOP_REFRESH_MS = 2_000;
42
+ /** The share of a stop past which counts are read again at NEAR_STOP_REFRESH_MS. */
43
+ const NEAR_STOP = 0.9;
44
+ /** Tenants one server remembers; past this the least recently checked tenth is dropped. */
45
+ const MAX_TENANTS = 50_000;
46
+ /** The month an instant falls in: its first day and the next month's first instant, UTC. */
47
+ export function slotlockQuotaPeriod(at) {
48
+ const date = new Date(at);
49
+ const year = date.getUTCFullYear();
50
+ const month = date.getUTCMonth();
51
+ return {
52
+ periodStart: new Date(Date.UTC(year, month, 1)).toISOString().slice(0, 10),
53
+ resetsAt: new Date(Date.UTC(year, month + 1, 1)),
54
+ };
55
+ }
56
+ export function createSlotlockQuotas(options) {
57
+ const now = options.now ?? Date.now;
58
+ const entries = new Map();
59
+ const reading = new Map();
60
+ const noted = new Set();
61
+ const report = (error) => {
62
+ try {
63
+ options.onError?.(error);
64
+ }
65
+ catch {
66
+ // A reporter's failure changes nothing.
67
+ }
68
+ };
69
+ async function read(tenantRef, periodStart) {
70
+ const at = now();
71
+ const local = options.local?.(tenantRef, 'operations', periodStart) ?? { unrecorded: 0, counted: 0 };
72
+ try {
73
+ const plan = await options.plan(tenantRef);
74
+ const used = plan ? await options.used(tenantRef, periodStart) : { operations: 0, bookings: 0 };
75
+ const entry = {
76
+ plan,
77
+ periodStart,
78
+ at,
79
+ used: { operations: Number(used.operations) || 0, bookings: Number(used.bookings) || 0 },
80
+ unrecorded: local.unrecorded,
81
+ countedAtRead: local.counted,
82
+ bookingsSinceRead: 0,
83
+ };
84
+ if (!entries.has(tenantRef) && entries.size >= MAX_TENANTS) {
85
+ for (const key of [...entries.keys()].slice(0, Math.ceil(MAX_TENANTS / 10)))
86
+ entries.delete(key);
87
+ }
88
+ entries.delete(tenantRef);
89
+ entries.set(tenantRef, entry);
90
+ return entry;
91
+ }
92
+ catch (error) {
93
+ report(error);
94
+ return null;
95
+ }
96
+ }
97
+ function standingsOf(tenantRef, entry, resetsAt) {
98
+ if (!entry.plan)
99
+ return [];
100
+ const quotas = entry.plan;
101
+ const local = options.local?.(tenantRef, 'operations', entry.periodStart);
102
+ // The read's unrecorded operations, and every one this meter counted since. A running total
103
+ // below the one at the read means the meter started its totals again: all of it is new.
104
+ const counted = local?.counted ?? entry.countedAtRead;
105
+ const operations = entry.used.operations + entry.unrecorded + (counted >= entry.countedAtRead ? counted - entry.countedAtRead : counted);
106
+ const bookings = entry.used.bookings + entry.bookingsSinceRead;
107
+ const standing = (metric, used) => ({
108
+ metric,
109
+ quota: quotas[metric],
110
+ stop: Math.floor((quotas[metric] * quotas.stopPercent) / 100),
111
+ used,
112
+ periodStart: entry.periodStart,
113
+ resetsAt,
114
+ });
115
+ return [standing('operations', operations), standing('bookings', bookings)];
116
+ }
117
+ function notify(tenantRef, standings) {
118
+ if (!options.notice)
119
+ return;
120
+ for (const standing of standings) {
121
+ for (const percent of SLOTLOCK_QUOTA_NOTICE_PERCENTS) {
122
+ if (standing.used * 100 < standing.quota * percent)
123
+ continue;
124
+ const key = `${tenantRef}\0${standing.periodStart}\0${standing.metric}\0${percent}`;
125
+ if (noted.has(key))
126
+ continue;
127
+ if (noted.size >= MAX_TENANTS)
128
+ noted.clear();
129
+ noted.add(key);
130
+ try {
131
+ options
132
+ .notice({
133
+ tenantRef,
134
+ metric: standing.metric,
135
+ percent,
136
+ used: standing.used,
137
+ quota: standing.quota,
138
+ periodStart: standing.periodStart,
139
+ })
140
+ .catch((error) => {
141
+ // Tried again at the next check that finds the share reached.
142
+ noted.delete(key);
143
+ report(error);
144
+ });
145
+ }
146
+ catch (error) {
147
+ noted.delete(key);
148
+ report(error);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ async check({ tenantRef, operation }) {
155
+ const at = now();
156
+ const { periodStart, resetsAt } = slotlockQuotaPeriod(at);
157
+ let entry = entries.get(tenantRef);
158
+ const fresh = (found) => {
159
+ if (found.periodStart !== periodStart)
160
+ return false;
161
+ const age = at - found.at;
162
+ if (age < 0 || age >= REFRESH_MS)
163
+ return false;
164
+ if (age < NEAR_STOP_REFRESH_MS)
165
+ return true;
166
+ // Near a stop, the counts are read again sooner: the estimate since the read is rougher.
167
+ return !standingsOf(tenantRef, found, resetsAt).some((standing) => standing.used >= standing.stop * NEAR_STOP);
168
+ };
169
+ if (!entry || !fresh(entry)) {
170
+ let pending = reading.get(tenantRef);
171
+ if (!pending) {
172
+ pending = read(tenantRef, periodStart).finally(() => reading.delete(tenantRef));
173
+ reading.set(tenantRef, pending);
174
+ }
175
+ const read_ = await pending;
176
+ // A read that failed keeps the last counts of this month, if any; else nothing binds.
177
+ if (read_)
178
+ entry = read_;
179
+ else if (entry?.periodStart !== periodStart)
180
+ return { ok: true, standings: [] };
181
+ }
182
+ if (!entry)
183
+ return { ok: true, standings: [] };
184
+ let standings = standingsOf(tenantRef, entry, resetsAt);
185
+ // Booking operations let run since the read are attempts, and some were refused or failed:
186
+ // before refusing one on that estimate, read the database's count again.
187
+ const booking = SLOTLOCK_BOOKING_OPERATIONS.includes(operation);
188
+ const bookingStanding = standings[1];
189
+ if (booking && entry.bookingsSinceRead > 0 && bookingStanding && bookingStanding.used >= bookingStanding.stop) {
190
+ let pending = reading.get(tenantRef);
191
+ if (!pending) {
192
+ pending = read(tenantRef, periodStart).finally(() => reading.delete(tenantRef));
193
+ reading.set(tenantRef, pending);
194
+ }
195
+ const again = await pending;
196
+ if (again) {
197
+ entry = again;
198
+ standings = standingsOf(tenantRef, entry, resetsAt);
199
+ }
200
+ }
201
+ const current = entry;
202
+ // Bookings are noticed as the database last counted them, never on the estimate since.
203
+ notify(tenantRef, standings.map((standing) => standing.metric === 'bookings' ? { ...standing, used: current.used.bookings } : standing));
204
+ if (SLOTLOCK_QUOTA_EXEMPT_OPERATIONS.includes(operation)) {
205
+ return { ok: true, standings };
206
+ }
207
+ const [operations, bookings] = standings;
208
+ if (operations && operations.used >= operations.stop) {
209
+ return { ok: false, exceeded: operations, standings };
210
+ }
211
+ if (booking && bookings && bookings.used >= bookings.stop) {
212
+ return { ok: false, exceeded: bookings, standings };
213
+ }
214
+ if (booking && current.plan)
215
+ current.bookingsSinceRead += 1;
216
+ return { ok: true, standings };
217
+ },
218
+ };
219
+ }
220
+ /** `slotlock.tenant_quota_usage`: the tenant's operations and bookings from `fromDay` until now. */
221
+ export async function readSlotlockQuotaUse(sql, tenantRef, fromDay) {
222
+ const [row] = await sql `
223
+ SELECT operations, bookings FROM slotlock.tenant_quota_usage(${tenantRef}::text, ${fromDay}::date)`;
224
+ return { operations: Number(row?.operations ?? 0), bookings: Number(row?.bookings ?? 0) };
225
+ }
226
+ /** `slotlock.note_usage_threshold`: true when this was the first notice of that share this month. */
227
+ export async function noteSlotlockQuotaThreshold(sql, notice) {
228
+ const [row] = await sql `
229
+ SELECT slotlock.note_usage_threshold(
230
+ ${notice.tenantRef}::text, ${notice.periodStart}::date, ${notice.metric}::text,
231
+ ${notice.percent}::integer, ${notice.used}::bigint, ${notice.quota}::bigint) AS noted`;
232
+ return row?.noted === true;
233
+ }
234
+ /** The refusal of an operation over a quota: `quota_exceeded` (429), naming the quota and its reset. */
235
+ export function slotlockQuotaRefusal(exceeded) {
236
+ return new SlotlockAgentOperationError('quota_exceeded', 429, {
237
+ metric: exceeded.metric,
238
+ quota: String(exceeded.quota),
239
+ stop: String(exceeded.stop),
240
+ used: String(exceeded.used),
241
+ resets_at: exceeded.resetsAt.toISOString(),
242
+ });
243
+ }
244
+ /** The answer's fields for a tenant's quotas: RateLimit list members, and Retry-After when refused. */
245
+ export function slotlockQuotaFields(standings, at) {
246
+ const rateLimit = [];
247
+ const policy = [];
248
+ let retryAfter = 1;
249
+ for (const standing of standings) {
250
+ const name = `${standing.metric}_month`;
251
+ const seconds = Math.max(1, Math.ceil((standing.resetsAt.getTime() - at) / 1_000));
252
+ const window = Math.round((standing.resetsAt.getTime() - Date.parse(`${standing.periodStart}T00:00:00Z`)) / 1_000);
253
+ rateLimit.push(`"${name}";r=${Math.max(0, standing.stop - standing.used)};t=${seconds}`);
254
+ policy.push(`"${name}";q=${standing.stop};w=${window}`);
255
+ retryAfter = Math.max(retryAfter, seconds);
256
+ }
257
+ return { rateLimit, policy, retryAfter };
258
+ }
package/rate-limit.d.ts CHANGED
@@ -2,7 +2,8 @@ import { type SlotlockAgentOperation, type SlotlockAgentPrincipal } from './agen
2
2
  import { type SlotlockApplicationOperation } from './application-api.js';
3
3
  import { type SlotlockLimits, type SlotlockRate } from './config.js';
4
4
  import type { SlotlockNodeServerFetchTarget } from './node-server.js';
5
- export type SlotlockRefusalReason = 'auth_refused' | 'rate_limited' | 'lookup_overflow' | 'series_writes_busy' | 'maintenance_busy' | 'request_too_large';
5
+ import { type SlotlockQuotas } from './quotas.js';
6
+ export type SlotlockRefusalReason = 'auth_refused' | 'rate_limited' | 'lookup_overflow' | 'series_writes_busy' | 'maintenance_busy' | 'request_too_large' | 'quota_exceeded';
6
7
  /** One `refusals` log line: counts since the last one, and the most refused clients as pseudonyms. */
7
8
  export interface SlotlockRefusalCounts extends Record<SlotlockRefusalReason, number> {
8
9
  window_seconds: number;
@@ -45,6 +46,14 @@ export interface SlotlockRequestLimitsOptions {
45
46
  shared?: {
46
47
  take(bucket: string, rate: SlotlockRate): Promise<boolean>;
47
48
  };
49
+ /**
50
+ * Monthly quotas (quotas.ts, SLOTLOCK_QUOTAS=on), checked by `quota`: an operation over its
51
+ * tenant's quota is refused `quota_exceeded` (429), with Retry-After at the month's end, and
52
+ * every operation's answer lists the tenant's quotas in its RateLimit fields.
53
+ */
54
+ quotas?: SlotlockQuotas;
55
+ /** Milliseconds since the epoch, for the quotas' reset; defaults to `Date.now`. */
56
+ clock?: () => number;
48
57
  }
49
58
  export interface SlotlockRequestLimits {
50
59
  /** The agent server's `authenticate`: the request's lookup from `fetch`, else a lookup now. */
@@ -53,6 +62,8 @@ export interface SlotlockRequestLimits {
53
62
  consumeRateLimit(args: {
54
63
  principal: SlotlockAgentPrincipal;
55
64
  operation: SlotlockAgentOperation | 'protocol';
65
+ /** A dry run of a write, charged as a read. */
66
+ dryRun?: boolean;
56
67
  }): Promise<boolean>;
57
68
  /**
58
69
  * For `authorize`: when the operation may expand a recurring series, or runs maintenance, take its
@@ -65,6 +76,15 @@ export interface SlotlockRequestLimits {
65
76
  operation: SlotlockAgentOperation | SlotlockApplicationOperation;
66
77
  input: Record<string, unknown>;
67
78
  }): Promise<void>;
79
+ /**
80
+ * For `authorize`: with `quotas`, refuse an operation over its tenant's monthly quota, throwing a
81
+ * `SlotlockAgentOperationError` (`quota_exceeded`, 429) that names the metric, the quota, the stop
82
+ * and when the month resets. Without `quotas`, or for a tenant on no plan, nothing.
83
+ */
84
+ quota(args: {
85
+ principal: SlotlockAgentPrincipal;
86
+ operation: SlotlockAgentOperation | SlotlockApplicationOperation;
87
+ }): Promise<void>;
68
88
  /** Put the limits in front of `target`: the listener's fetch target. */
69
89
  serve(target: SlotlockNodeServerFetchTarget): SlotlockNodeServerFetchTarget;
70
90
  /** The client address a request reaching `serve`'s target was counted as (the dashboard's). */
package/rate-limit.js CHANGED
@@ -10,6 +10,9 @@
10
10
  // draft-ietf-httpapi-ratelimit-headers-11 (23 May 2026, read 2026-10-24): `"<policy>";r=<remaining>;
11
11
  // t=<seconds>` and `"<policy>";q=<quota>;w=<window seconds>`, or `qu="concurrent-requests"` for a
12
12
  // cap on requests at once. Retry-After is never earlier than the end of the effective window (§6).
13
+ // With monthly quotas (quotas.ts), each operation's answer also lists them, `"operations_month"` and
14
+ // `"bookings_month"`, counting down to the stop, and one over its quota is refused `quota_exceeded`
15
+ // with Retry-After at the month's end.
13
16
  import { AsyncLocalStorage } from 'node:async_hooks';
14
17
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
15
18
  import { isIP } from 'node:net';
@@ -17,6 +20,7 @@ import { SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, Sl
17
20
  import { slotlockBearerCredential } from './api-keys.js';
18
21
  import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
19
22
  import { SLOTLOCK_TRUSTED_PROXY_PROOF_FIELD, } from './config.js';
23
+ import { slotlockQuotaFields, slotlockQuotaRefusal } from './quotas.js';
20
24
  import { SLOTLOCK_APPLICATION_REST_ROUTES, matchSlotlockRestRoute } from './rest-api.js';
21
25
  /** Keys one policy remembers; past this, the least recently used tenth is dropped at once. */
22
26
  const MAX_BUCKET_KEYS = 100_000;
@@ -47,6 +51,8 @@ const REFUSAL_LOG_INTERVAL_MS = 60_000;
47
51
  const MAX_LOGGED_CLIENTS = 10;
48
52
  /** Clients counted between two log lines; past this the rest are counted in the totals only. */
49
53
  const MAX_COUNTED_CLIENTS = 1_000;
54
+ /** Distinct per-key rates (RFC 0004 A5) one server keeps buckets for; past this the oldest goes. */
55
+ const MAX_KEY_LIMIT_RATES = 1_000;
50
56
  /** The IPv6 /64 of an address, which one client is generally assigned whole; null if not IPv6. */
51
57
  function ipv6Prefix(address) {
52
58
  let text = address.toLowerCase();
@@ -427,12 +433,27 @@ function slotsFor(operation, input) {
427
433
  /** The limits `slotlock serve` puts in front of its agent server and dashboard. */
428
434
  export function createSlotlockRequestLimits(options) {
429
435
  const now = options.now ?? (() => performance.now());
436
+ const clock = options.clock ?? Date.now;
430
437
  const { rates, caps } = options.limits;
431
438
  const policies = new Map();
432
439
  for (const [name, rate] of Object.entries(rates)) {
433
440
  if (rate !== null)
434
441
  policies.set(name, { policy: { name, rate }, buckets: bucketsFor(rate) });
435
442
  }
443
+ // Keys' own rates (RFC 0004 A5), one set of buckets for each rate in use; a key's bucket is keyed
444
+ // by key and rate, so a changed rate starts a fresh bucket.
445
+ const keyLimits = new Map();
446
+ const keyLimitEntry = (perMinute) => {
447
+ let entry = keyLimits.get(perMinute);
448
+ if (!entry) {
449
+ if (keyLimits.size >= MAX_KEY_LIMIT_RATES)
450
+ keyLimits.delete(keyLimits.keys().next().value);
451
+ const rate = { perSecond: perMinute / 60, burst: perMinute };
452
+ entry = { policy: { name: 'key_limit', rate }, buckets: bucketsFor(rate) };
453
+ keyLimits.set(perMinute, entry);
454
+ }
455
+ return entry;
456
+ };
436
457
  const states = new AsyncLocalStorage();
437
458
  const looked = new WeakMap();
438
459
  const addresses = new WeakMap();
@@ -468,6 +489,7 @@ export function createSlotlockRequestLimits(options) {
468
489
  series_writes_busy: 0,
469
490
  maintenance_busy: 0,
470
491
  request_too_large: 0,
492
+ quota_exceeded: 0,
471
493
  };
472
494
  }
473
495
  const count = (reason, address) => {
@@ -508,8 +530,8 @@ export function createSlotlockRequestLimits(options) {
508
530
  */
509
531
  function charge(checks) {
510
532
  const at = now();
511
- const found = checks.flatMap(({ name, key }) => {
512
- const entry = policies.get(name);
533
+ const found = checks.flatMap(({ name, key, entry: given }) => {
534
+ const entry = given ?? policies.get(name);
513
535
  return entry ? [{ policy: entry.policy, bucket: entry.buckets.get(key, at) }] : [];
514
536
  });
515
537
  if (found.length === 0)
@@ -551,15 +573,21 @@ export function createSlotlockRequestLimits(options) {
551
573
  const keyOf = `${principal.tenantRef}\0${principal.subject}`;
552
574
  const application = state.application &&
553
575
  (principal.scopes === undefined || principal.scopes.includes(SLOTLOCK_APPLICATION_SCOPE));
554
- const checks = due.flatMap((kind) => [
555
- ...(application
556
- ? [{ name: `app_${kind}`, key: principal.tenantRef }]
557
- : [
558
- { name: `key_${kind}`, key: keyOf },
559
- { name: `tenant_${kind}`, key: principal.tenantRef },
560
- ]),
561
- { name: `global_${kind}`, key: '' },
562
- ]);
576
+ // A key's own rate (RFC 0004 A5): a minute's requests, reads and writes alike, as a bucket of
577
+ // its own beside the server's, so it only ever lowers what the key may do.
578
+ const own = principal.limits?.requestsPerMinute;
579
+ const keyLimit = own !== undefined && Number.isSafeInteger(own) && own >= 1 && !state.chargedKeyLimit
580
+ ? [{ name: 'key_limit', key: `${keyOf}\0${own}`, entry: keyLimitEntry(own) }]
581
+ : [];
582
+ const checks = [...keyLimit, ...due.flatMap((kind) => [
583
+ ...(application
584
+ ? [{ name: `app_${kind}`, key: principal.tenantRef }]
585
+ : [
586
+ { name: `key_${kind}`, key: keyOf },
587
+ { name: `tenant_${kind}`, key: principal.tenantRef },
588
+ ]),
589
+ { name: `global_${kind}`, key: '' },
590
+ ])];
563
591
  const outcome = charge(checks);
564
592
  if (!outcome.ok) {
565
593
  state.refusal = {
@@ -593,6 +621,8 @@ export function createSlotlockRequestLimits(options) {
593
621
  }
594
622
  for (const kind of due)
595
623
  state.charged[kind] = true;
624
+ if (keyLimit.length > 0)
625
+ state.chargedKeyLimit = true;
596
626
  if (outcome.verdict)
597
627
  state.tightest = outcome.verdict;
598
628
  return true;
@@ -628,17 +658,18 @@ export function createSlotlockRequestLimits(options) {
628
658
  return looked.get(request) ?? null;
629
659
  return options.authenticate(request);
630
660
  },
631
- async consumeRateLimit({ principal, operation }) {
661
+ async consumeRateLimit({ principal, operation, dryRun }) {
632
662
  const state = states.getStore();
633
663
  if (!state)
634
664
  return true;
635
665
  const classes = [];
636
666
  if (operation === 'protocol') {
637
667
  classes.push('read');
638
- if (state.write)
668
+ if (state.write && dryRun !== true)
639
669
  classes.push('write');
640
670
  }
641
- else if (slotlockAgentOperationScope(operation) === 'write') {
671
+ else if (slotlockAgentOperationScope(operation) === 'write' && dryRun !== true) {
672
+ // A dry run writes nothing: the request's read token is its charge (RFC 0004 A3).
642
673
  classes.push('write');
643
674
  }
644
675
  return chargeRequest(state, principal, classes);
@@ -677,6 +708,33 @@ export function createSlotlockRequestLimits(options) {
677
708
  };
678
709
  throw new SlotlockAgentOperationError('server_busy', 503);
679
710
  },
711
+ async quota({ principal, operation }) {
712
+ if (!options.quotas)
713
+ return;
714
+ const state = states.getStore();
715
+ const verdict = await options.quotas.check({ tenantRef: principal.tenantRef, operation });
716
+ const at = clock();
717
+ if (verdict.ok) {
718
+ if (state && verdict.standings.length > 0) {
719
+ const { rateLimit, policy } = slotlockQuotaFields(verdict.standings, at);
720
+ state.quota = { rateLimit, policy };
721
+ }
722
+ return;
723
+ }
724
+ const { exceeded } = verdict;
725
+ const { rateLimit, policy, retryAfter } = slotlockQuotaFields([exceeded], at);
726
+ if (state) {
727
+ state.refusal = {
728
+ reason: 'quota_exceeded',
729
+ fields: {
730
+ 'Retry-After': String(retryAfter),
731
+ RateLimit: rateLimit.join(', '),
732
+ 'RateLimit-Policy': policy.join(', '),
733
+ },
734
+ };
735
+ }
736
+ throw slotlockQuotaRefusal(exceeded);
737
+ },
680
738
  serve(target) {
681
739
  const limited = {
682
740
  async fetch(request, connection) {
@@ -745,10 +803,19 @@ export function createSlotlockRequestLimits(options) {
745
803
  count('request_too_large', address);
746
804
  if (state.refusal)
747
805
  count(state.refusal.reason, address);
748
- const fields = state.refusal?.fields ??
806
+ let fields = state.refusal?.fields ??
749
807
  (state.tightest && (route === 'mcp' || route === 'a2a' || route === 'rest')
750
808
  ? fieldsOf(state.tightest, false)
751
809
  : undefined);
810
+ // The tenant's monthly quotas follow the bucket the answer reports, when nothing refused.
811
+ if (state.quota && !state.refusal) {
812
+ const join = (first, rest) => [...(first ? [first] : []), ...rest].join(', ');
813
+ fields = {
814
+ ...fields,
815
+ RateLimit: join(fields?.RateLimit, state.quota.rateLimit),
816
+ 'RateLimit-Policy': join(fields?.['RateLimit-Policy'], state.quota.policy),
817
+ };
818
+ }
752
819
  if (!fields)
753
820
  return response;
754
821
  // A response's own headers may be immutable (Response.redirect): answer with a copy.