slotlock 0.6.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- 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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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.
|