@constructive-io/express-context 0.26.1 → 0.27.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/refusals.js ADDED
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ /**
3
+ * refusals — the refusal-observability instantiation of `usage-counter`.
4
+ *
5
+ * Every refusal the platform issues (a 429 from admission control, a 413 for an
6
+ * oversized body, a document-gate rejection, a gateway's route or IP window)
7
+ * is one `RefusalRecorder.record(...)` call at the point the response is
8
+ * written. That call is an in-memory increment and nothing else — no promise,
9
+ * no pool, no allocation beyond the key — so a flood costs a counter bump and
10
+ * refusing traffic keeps working when the recorder's store is down.
11
+ *
12
+ * Counts are keyed by `(minute, database, lane, reason, route, source /24)`,
13
+ * which is exactly the set of questions the platform table answers: which
14
+ * tenant, why, on which route, from which network, since when. Raw addresses
15
+ * are never part of the key; `sourceBucket` truncates to a /24 (v4) or /48
16
+ * (v6) before anything is counted.
17
+ *
18
+ * The flush writes the drained batch as one `record_refusals(jsonb)` call —
19
+ * one statement per window regardless of how many keys it holds.
20
+ *
21
+ * @module refusals
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.createRecordRefusalsSink = exports.RefusalRecorder = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.refusalRows = exports.refusalOverflowKey = exports.refusalKeyOf = exports.sourceBucket = exports.OVERFLOW_ROUTE = exports.OVERFLOW_SOURCE = exports.UNKNOWN_SOURCE = exports.REFUSAL_REASONS = void 0;
25
+ const logger_1 = require("@pgpmjs/logger");
26
+ const pg_query_context_1 = require("pg-query-context");
27
+ const usage_counter_1 = require("./usage-counter");
28
+ const log = new logger_1.Logger('refusals');
29
+ exports.REFUSAL_REASONS = [
30
+ 'rate_limited',
31
+ 'concurrency_saturated',
32
+ 'queue_timeout',
33
+ 'request_too_large',
34
+ 'query_too_deep',
35
+ 'query_too_costly',
36
+ 'page_size_too_large',
37
+ 'anonymous_not_callable',
38
+ 'route_rate_limited'
39
+ ];
40
+ /** `source_bucket` when the lane could not resolve an address. */
41
+ exports.UNKNOWN_SOURCE = 'unknown';
42
+ /** `source_bucket` / `route_key` of the fold-down entry once the counter is full. */
43
+ exports.OVERFLOW_SOURCE = 'overflow';
44
+ exports.OVERFLOW_ROUTE = '*';
45
+ const MINUTE_MS = 60_000;
46
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
47
+ /**
48
+ * Reduce a client address to a network prefix: `/24` for IPv4, `/48` for IPv6.
49
+ * Enough to tell "one network hammering an anonymous route" from "the tenant's
50
+ * own users"; not enough to be a person. Anything unparseable is `unknown`.
51
+ */
52
+ const sourceBucket = (ip) => {
53
+ if (!ip)
54
+ return exports.UNKNOWN_SOURCE;
55
+ let addr = ip.trim();
56
+ if (addr.startsWith('[')) {
57
+ const end = addr.indexOf(']');
58
+ if (end === -1)
59
+ return exports.UNKNOWN_SOURCE;
60
+ addr = addr.slice(1, end);
61
+ }
62
+ const zone = addr.indexOf('%');
63
+ if (zone !== -1)
64
+ addr = addr.slice(0, zone);
65
+ if (addr.toLowerCase().startsWith('::ffff:')) {
66
+ const mapped = addr.slice('::ffff:'.length);
67
+ if (IPV4.test(mapped))
68
+ addr = mapped;
69
+ }
70
+ const v4 = IPV4.exec(addr);
71
+ if (v4) {
72
+ const octets = v4.slice(1, 5).map(Number);
73
+ if (octets.some((o) => o > 255))
74
+ return exports.UNKNOWN_SOURCE;
75
+ return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
76
+ }
77
+ const groups = expandIpv6(addr);
78
+ if (!groups)
79
+ return exports.UNKNOWN_SOURCE;
80
+ return `${groups[0]}:${groups[1]}:${groups[2]}::/48`;
81
+ };
82
+ exports.sourceBucket = sourceBucket;
83
+ /** The eight hextets of an IPv6 address, or null when it does not parse. */
84
+ const expandIpv6 = (addr) => {
85
+ if (!/^[0-9a-fA-F:]+$/.test(addr) || addr.indexOf(':::') !== -1)
86
+ return null;
87
+ const halves = addr.split('::');
88
+ if (halves.length > 2)
89
+ return null;
90
+ const head = halves[0] ? halves[0].split(':') : [];
91
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
92
+ const missing = 8 - head.length - tail.length;
93
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
94
+ return null;
95
+ const groups = [...head, ...Array(missing).fill('0'), ...tail];
96
+ if (groups.some((g) => g.length === 0 || g.length > 4))
97
+ return null;
98
+ return groups.map((g) => parseInt(g, 16).toString(16));
99
+ };
100
+ const minuteOf = (at) => Math.floor(at / MINUTE_MS) * MINUTE_MS;
101
+ const refusalKeyOf = (key) => `${key.minuteBucket}\u0000${key.databaseId ?? ''}\u0000${key.lane}\u0000${key.reason}\u0000${key.routeKey}\u0000${key.sourceBucket}`;
102
+ exports.refusalKeyOf = refusalKeyOf;
103
+ /**
104
+ * Where a key folds to once the counter is full: same minute, tenant, lane and
105
+ * reason; route and source collapsed. A source-spraying flood therefore costs
106
+ * `maxKeys` entries plus one per `(database, lane, reason)`, the tenant/reason
107
+ * attribution is never lost, and the presence of an `overflow` row is itself
108
+ * the signal that a spray happened.
109
+ */
110
+ const refusalOverflowKey = (key) => ({
111
+ ...key,
112
+ routeKey: exports.OVERFLOW_ROUTE,
113
+ sourceBucket: exports.OVERFLOW_SOURCE
114
+ });
115
+ exports.refusalOverflowKey = refusalOverflowKey;
116
+ /** Shape a drained batch for `record_refusals(jsonb)`. */
117
+ const refusalRows = (entries) => entries.map(({ key, count, firstAt, lastAt }) => ({
118
+ minute_bucket: new Date(key.minuteBucket).toISOString(),
119
+ database_id: key.databaseId,
120
+ lane: key.lane,
121
+ reason: key.reason,
122
+ route_key: key.routeKey,
123
+ source_bucket: key.sourceBucket,
124
+ count,
125
+ first_seen_at: new Date(firstAt).toISOString(),
126
+ last_seen_at: new Date(lastAt).toISOString()
127
+ }));
128
+ exports.refusalRows = refusalRows;
129
+ exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = 10_000;
130
+ exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = 2_000;
131
+ exports.DEFAULT_REFUSAL_MAX_KEYS = 2_000;
132
+ const defaultOnError = (err, dropped) => {
133
+ const refusals = dropped.reduce((sum, e) => sum + e.count, 0);
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ log.error(`[refusals] flush failed; dropped ${refusals} refusals across ${dropped.length} keys: ${message}`);
136
+ };
137
+ /**
138
+ * `BoundedCounter<RefusalKey>` + `CounterFlusher` + a `record_refusals` sink.
139
+ * Construct one per process, `start()` it, `record()` from every refusal site,
140
+ * `stop()` on shutdown.
141
+ */
142
+ class RefusalRecorder {
143
+ counter;
144
+ flusher;
145
+ recordFailures = 0;
146
+ constructor(opts) {
147
+ this.counter = new usage_counter_1.BoundedCounter({
148
+ maxKeys: opts.maxKeys ?? exports.DEFAULT_REFUSAL_MAX_KEYS,
149
+ keyOf: exports.refusalKeyOf,
150
+ overflowKey: exports.refusalOverflowKey
151
+ });
152
+ this.flusher = new usage_counter_1.CounterFlusher(this.counter, (entries) => opts.sink((0, exports.refusalRows)(entries)), {
153
+ intervalMs: opts.intervalMs ?? exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS,
154
+ jitterMs: opts.jitterMs ?? exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS,
155
+ onError: opts.onError ?? defaultOnError
156
+ });
157
+ }
158
+ /**
159
+ * Count one refusal. Synchronous and never throws: the request that is
160
+ * being refused must finish being refused whatever state the recorder is in.
161
+ */
162
+ record(refusal) {
163
+ try {
164
+ const at = refusal.at ?? Date.now();
165
+ this.counter.increment({
166
+ minuteBucket: minuteOf(at),
167
+ databaseId: refusal.databaseId,
168
+ lane: refusal.lane,
169
+ reason: refusal.reason,
170
+ routeKey: refusal.routeKey,
171
+ sourceBucket: (0, exports.sourceBucket)(refusal.sourceIp)
172
+ }, 1, at);
173
+ }
174
+ catch (err) {
175
+ this.recordFailures += 1;
176
+ const message = err instanceof Error ? err.message : String(err);
177
+ log.error(`[refusals] record failed (refusal still served): ${message}`);
178
+ }
179
+ }
180
+ start() {
181
+ this.flusher.start();
182
+ }
183
+ /** Final flush, then stop. */
184
+ stop() {
185
+ return this.flusher.stop();
186
+ }
187
+ /** Drain and write now; a sink failure is reported, never thrown. */
188
+ flush() {
189
+ return this.flusher.flush();
190
+ }
191
+ stats() {
192
+ return {
193
+ keys: this.counter.size,
194
+ overflowed: this.counter.overflowed,
195
+ recordFailures: this.recordFailures,
196
+ flusher: this.flusher.stats()
197
+ };
198
+ }
199
+ }
200
+ exports.RefusalRecorder = RefusalRecorder;
201
+ const FUNCTION_NAME = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
202
+ /**
203
+ * A sink that writes a batch as one `record_refusals(jsonb)` call inside a
204
+ * transaction carrying the caller's claims. Claims are resolved per flush so a
205
+ * pool that was unreachable at startup does not leave the recorder wedged.
206
+ */
207
+ const createRecordRefusalsSink = (opts) => {
208
+ const fn = opts.functionName ?? 'constructive_usage_private.record_refusals';
209
+ if (!FUNCTION_NAME.test(fn)) {
210
+ throw new Error(`createRecordRefusalsSink: invalid function name ${JSON.stringify(fn)}`);
211
+ }
212
+ const [schema, name] = fn.split('.');
213
+ const sql = `SELECT "${schema}"."${name}"($1::jsonb) AS recorded`;
214
+ return async (rows) => {
215
+ if (rows.length === 0)
216
+ return;
217
+ const claims = await opts.claims();
218
+ await (0, pg_query_context_1.withPgClient)(opts.pool, claims, async (client) => {
219
+ await client.query(sql, [JSON.stringify(rows)]);
220
+ });
221
+ };
222
+ };
223
+ exports.createRecordRefusalsSink = createRecordRefusalsSink;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * request-protection — platform bounds for the per-request protection settings.
3
+ *
4
+ * `database_settings` / `api_settings` carry a tenant's *preferences*; this
5
+ * module owns the numbers the platform will actually honour. The platform
6
+ * default and the hard ceiling are code constants rather than rows, so a
7
+ * tenant can never widen a bound by writing metadata: a setting is only ever
8
+ * read as a request to be *stricter* than the platform is.
9
+ *
10
+ * effective = clamp(LEAST(api_override, database_setting) ?? default, floor, max)
11
+ *
12
+ * `LEAST` (not `COALESCE`) is what makes an API override lower-only: an API
13
+ * that asks for a 60s statement timeout under a database that says 10s gets
14
+ * 10s. The floor exists so a tenant cannot write a value that makes its own
15
+ * API unusable (a 0ms statement timeout), and the ceiling so it cannot raise
16
+ * one past what the shared cluster will absorb.
17
+ */
18
+ /** Every request-protection bound, resolved to a value the runtime enforces. */
19
+ export interface RequestProtection {
20
+ /** `statement_timeout` for the request's transaction. */
21
+ statementTimeoutMs: number;
22
+ /** `idle_in_transaction_session_timeout` for the request's transaction. */
23
+ idleInTransactionTimeoutMs: number;
24
+ /** `lock_timeout` for the request's transaction. */
25
+ lockTimeoutMs: number;
26
+ /** In-flight requests admitted per database, per replica. */
27
+ maxConcurrentRequests: number;
28
+ /** How long a request may wait for a concurrency slot. `0` never queues. */
29
+ maxQueueWaitMs: number;
30
+ /** Sustained request rate allowed one caller on one route. */
31
+ rateLimitRpm: number;
32
+ /** Burst allowance above `rateLimitRpm` within the same window. */
33
+ rateLimitBurst: number;
34
+ /** Deepest selection set accepted in an operation. */
35
+ maxQueryDepth: number;
36
+ /** Largest static cost accepted in an operation. */
37
+ maxQueryCost: number;
38
+ /** Largest `first`/`last` accepted on a connection. */
39
+ maxPageSize: number;
40
+ /** Largest request body accepted. */
41
+ maxRequestBytes: number;
42
+ /** Whether the schema answers introspection queries. */
43
+ enableIntrospection: boolean;
44
+ }
45
+ /** The numeric bounds — every field of {@link RequestProtection} but the flag. */
46
+ export type NumericProtectionKey = Exclude<keyof RequestProtection, 'enableIntrospection'>;
47
+ /** What the platform grants, and the window a tenant may move inside. */
48
+ export interface ProtectionBound {
49
+ /** Applied when neither scope expresses a preference. */
50
+ default: number;
51
+ /** Lowest value a tenant can make effective — below this its API breaks. */
52
+ floor: number;
53
+ /** Highest value the platform honours, whatever metadata says. */
54
+ max: number;
55
+ }
56
+ /**
57
+ * The platform's own numbers. Deliberately constants: a ceiling stored next to
58
+ * the value it caps is a ceiling a tenant can edit.
59
+ */
60
+ export declare const PROTECTION_BOUNDS: Record<NumericProtectionKey, ProtectionBound>;
61
+ /**
62
+ * What a connection with no `first`/`last` is charged.
63
+ *
64
+ * Such a field is unbounded in principle, so charging it the tenant's
65
+ * `maxPageSize` would make a large ceiling self-defeating: raising the page
66
+ * size a client *may* ask for would shrink the query it can write. A fixed
67
+ * estimate keeps the two bounds independent.
68
+ */
69
+ export declare const ASSUMED_PAGE_SIZE = 100;
70
+ /**
71
+ * Introspection stays on until metadata says otherwise, and `platformAllows`
72
+ * is the kill switch that takes it away cluster-wide regardless.
73
+ *
74
+ * The platform default is permissive here where the metadata default is not
75
+ * (`database_settings.enable_introspection` is `NOT NULL DEFAULT false`) on
76
+ * purpose: this value applies to a database that has expressed *no*
77
+ * preference — typically one whose routing plane predates the column — and
78
+ * silently disabling introspection for it would break GraphiQL and codegen for
79
+ * every tenant the moment this code deployed, without anyone opting in.
80
+ */
81
+ export declare const INTROSPECTION_BOUND: {
82
+ default: boolean;
83
+ platformAllows: boolean;
84
+ };
85
+ /** What a database with no settings row gets. */
86
+ export declare const DEFAULT_REQUEST_PROTECTION: RequestProtection;
87
+ /**
88
+ * One scope's stored preferences. Every field is nullable — `null` is "no
89
+ * preference at this scope", which is why the resolver never treats a missing
90
+ * value as a zero.
91
+ */
92
+ export interface RequestProtectionInput {
93
+ statementTimeoutMs?: number | null;
94
+ idleInTransactionTimeoutMs?: number | null;
95
+ lockTimeoutMs?: number | null;
96
+ maxConcurrentRequests?: number | null;
97
+ maxQueueWaitMs?: number | null;
98
+ rateLimitRpm?: number | null;
99
+ rateLimitBurst?: number | null;
100
+ maxQueryDepth?: number | null;
101
+ maxQueryCost?: number | null;
102
+ maxPageSize?: number | null;
103
+ maxRequestBytes?: number | null;
104
+ enableIntrospection?: boolean | null;
105
+ }
106
+ /**
107
+ * Resolve the bounds a request runs under.
108
+ *
109
+ * @param database - the `database_settings` row for the tenant, if any
110
+ * @param api - the `api_settings` overrides for the API the request arrived on
111
+ */
112
+ export declare function resolveRequestProtection(database?: RequestProtectionInput | null, api?: RequestProtectionInput | null): RequestProtection;
113
+ /**
114
+ * The timeout GUCs as `SET LOCAL` pairs, for merging into a request's
115
+ * pgSettings. PostgreSQL reads a bare integer for all three as milliseconds.
116
+ */
117
+ export declare function protectionPgSettings(protection: RequestProtection): Record<string, string>;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * request-protection — platform bounds for the per-request protection settings.
4
+ *
5
+ * `database_settings` / `api_settings` carry a tenant's *preferences*; this
6
+ * module owns the numbers the platform will actually honour. The platform
7
+ * default and the hard ceiling are code constants rather than rows, so a
8
+ * tenant can never widen a bound by writing metadata: a setting is only ever
9
+ * read as a request to be *stricter* than the platform is.
10
+ *
11
+ * effective = clamp(LEAST(api_override, database_setting) ?? default, floor, max)
12
+ *
13
+ * `LEAST` (not `COALESCE`) is what makes an API override lower-only: an API
14
+ * that asks for a 60s statement timeout under a database that says 10s gets
15
+ * 10s. The floor exists so a tenant cannot write a value that makes its own
16
+ * API unusable (a 0ms statement timeout), and the ceiling so it cannot raise
17
+ * one past what the shared cluster will absorb.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.DEFAULT_REQUEST_PROTECTION = exports.INTROSPECTION_BOUND = exports.ASSUMED_PAGE_SIZE = exports.PROTECTION_BOUNDS = void 0;
21
+ exports.resolveRequestProtection = resolveRequestProtection;
22
+ exports.protectionPgSettings = protectionPgSettings;
23
+ // ─── Platform bounds ────────────────────────────────────────────────────────
24
+ /**
25
+ * The platform's own numbers. Deliberately constants: a ceiling stored next to
26
+ * the value it caps is a ceiling a tenant can edit.
27
+ */
28
+ exports.PROTECTION_BOUNDS = {
29
+ statementTimeoutMs: { default: 30_000, floor: 1_000, max: 120_000 },
30
+ idleInTransactionTimeoutMs: { default: 60_000, floor: 1_000, max: 300_000 },
31
+ lockTimeoutMs: { default: 5_000, floor: 100, max: 60_000 },
32
+ maxConcurrentRequests: { default: 50, floor: 1, max: 500 },
33
+ maxQueueWaitMs: { default: 5_000, floor: 0, max: 60_000 },
34
+ rateLimitRpm: { default: 600, floor: 1, max: 60_000 },
35
+ rateLimitBurst: { default: 60, floor: 1, max: 10_000 },
36
+ maxQueryDepth: { default: 12, floor: 1, max: 50 },
37
+ // Cost is measured in rows a document can pull (see ASSUMED_PAGE_SIZE), so
38
+ // the default has to leave room for the nesting Graphile encourages while
39
+ // still refusing the millions-of-rows shapes.
40
+ maxQueryCost: { default: 1_000_000, floor: 100, max: 100_000_000 },
41
+ maxPageSize: { default: 1_000, floor: 1, max: 10_000 },
42
+ maxRequestBytes: { default: 1_000_000, floor: 1_024, max: 10_000_000 }
43
+ };
44
+ /**
45
+ * What a connection with no `first`/`last` is charged.
46
+ *
47
+ * Such a field is unbounded in principle, so charging it the tenant's
48
+ * `maxPageSize` would make a large ceiling self-defeating: raising the page
49
+ * size a client *may* ask for would shrink the query it can write. A fixed
50
+ * estimate keeps the two bounds independent.
51
+ */
52
+ exports.ASSUMED_PAGE_SIZE = 100;
53
+ /**
54
+ * Introspection stays on until metadata says otherwise, and `platformAllows`
55
+ * is the kill switch that takes it away cluster-wide regardless.
56
+ *
57
+ * The platform default is permissive here where the metadata default is not
58
+ * (`database_settings.enable_introspection` is `NOT NULL DEFAULT false`) on
59
+ * purpose: this value applies to a database that has expressed *no*
60
+ * preference — typically one whose routing plane predates the column — and
61
+ * silently disabling introspection for it would break GraphiQL and codegen for
62
+ * every tenant the moment this code deployed, without anyone opting in.
63
+ */
64
+ exports.INTROSPECTION_BOUND = {
65
+ default: true,
66
+ platformAllows: true
67
+ };
68
+ /** What a database with no settings row gets. */
69
+ exports.DEFAULT_REQUEST_PROTECTION = resolveDefaults();
70
+ function resolveDefaults() {
71
+ const numeric = Object.fromEntries(Object.entries(exports.PROTECTION_BOUNDS).map(([key, bound]) => [key, bound.default]));
72
+ return { ...numeric, enableIntrospection: exports.INTROSPECTION_BOUND.default };
73
+ }
74
+ // ─── Resolution ─────────────────────────────────────────────────────────────
75
+ const clamp = (value, bound) => Math.min(Math.max(value, bound.floor), bound.max);
76
+ /** The stricter of two preferences, ignoring the scopes that expressed none. */
77
+ const strictest = (a, b) => {
78
+ const values = [a, b].filter((v) => typeof v === 'number' && Number.isFinite(v));
79
+ return values.length > 0 ? Math.min(...values) : null;
80
+ };
81
+ /**
82
+ * Resolve the bounds a request runs under.
83
+ *
84
+ * @param database - the `database_settings` row for the tenant, if any
85
+ * @param api - the `api_settings` overrides for the API the request arrived on
86
+ */
87
+ function resolveRequestProtection(database, api) {
88
+ const resolved = {};
89
+ for (const [name, bound] of Object.entries(exports.PROTECTION_BOUNDS)) {
90
+ const preferred = strictest(database?.[name], api?.[name]);
91
+ resolved[name] = clamp(preferred ?? bound.default, bound);
92
+ }
93
+ // Lower-only applies to the flag too: an API may switch introspection off
94
+ // for itself, never back on for a database that turned it off, and the
95
+ // platform switch can only ever take it away.
96
+ const enableIntrospection = (database?.enableIntrospection ?? exports.INTROSPECTION_BOUND.default) &&
97
+ (api?.enableIntrospection ?? true) &&
98
+ exports.INTROSPECTION_BOUND.platformAllows;
99
+ return { ...resolved, enableIntrospection };
100
+ }
101
+ /**
102
+ * The timeout GUCs as `SET LOCAL` pairs, for merging into a request's
103
+ * pgSettings. PostgreSQL reads a bare integer for all three as milliseconds.
104
+ */
105
+ function protectionPgSettings(protection) {
106
+ return {
107
+ statement_timeout: String(protection.statementTimeoutMs),
108
+ idle_in_transaction_session_timeout: String(protection.idleInTransactionTimeoutMs),
109
+ lock_timeout: String(protection.lockTimeoutMs)
110
+ };
111
+ }
package/types.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { Pool, PoolClient } from 'pg';
10
10
  import type { BillingClient } from './billing-client';
11
+ import type { RequestProtection } from './request-protection';
11
12
  export interface DatabaseSettings {
12
13
  enableAggregates: boolean;
13
14
  enablePostgis: boolean;
@@ -226,6 +227,7 @@ export interface BuiltinModuleMap {
226
227
  agentChat: AgentChatConfig;
227
228
  llm: LlmConfig;
228
229
  compute: ComputeConfig;
230
+ requestProtection: RequestProtection;
229
231
  }
230
232
  /**
231
233
  * Callback for executing queries within a tenant-scoped transaction
@@ -0,0 +1,137 @@
1
+ /**
2
+ * usage-counter — the shared in-memory count + batch-flush mechanism.
3
+ *
4
+ * Two classes, neither of which knows what is being counted:
5
+ *
6
+ * - `BoundedCounter<K>` is the map. Consumers pass the key type, how a key
7
+ * serialises (`keyOf`) and where a key folds to once the map is full
8
+ * (`overflowKey`). `increment` is O(1), synchronous and never throws, so a
9
+ * request path can call it while refusing traffic without ever waiting on,
10
+ * or being affected by, whatever eventually stores the counts.
11
+ * - `CounterFlusher<K>` is the loop. It drains the counter on a jittered
12
+ * interval, hands the batch to a `sink`, and owns the failure policy: a
13
+ * batch the sink cannot write is reported through `onError` *and dropped*.
14
+ * Re-queuing under a broken sink is how a bounded counter becomes an
15
+ * unbounded one, and the counter is already accumulating the next window.
16
+ *
17
+ * Refusal observability instantiates these with a refusal key and a sink that
18
+ * calls `constructive_usage_private.record_refusals` (see `refusals.ts`); the
19
+ * page/request metering lane instantiates the same two classes with its own
20
+ * `K`, `keyOf`, `overflowKey`, bound, interval and sink. The seam is the
21
+ * constructor arguments — nothing in this file is specific to either.
22
+ *
23
+ * Counters live in process memory only. A crash loses at most one interval's
24
+ * worth of counts; `stop()` flushes on graceful shutdown. That is deliberate:
25
+ * the alternative is a durable write per event, which is the thing counting in
26
+ * memory exists to avoid.
27
+ *
28
+ * @module usage-counter
29
+ */
30
+ export interface CounterEntry<K> {
31
+ key: K;
32
+ /** Sum of every `by` since this entry was created (or last drained). */
33
+ count: number;
34
+ /** Earliest `at` folded into this entry. */
35
+ firstAt: number;
36
+ /** Latest `at` folded into this entry. */
37
+ lastAt: number;
38
+ }
39
+ export interface BoundedCounterOptions<K> {
40
+ /**
41
+ * Distinct keys the counter will hold between drains. Past this, a *new* key
42
+ * is replaced by `overflowKey(key)` before it is counted; keys already
43
+ * present keep incrementing. Overflow keys are always admitted, so the true
44
+ * ceiling is `maxKeys` plus the number of distinct overflow keys the
45
+ * consumer's `overflowKey` can produce — keep that small.
46
+ */
47
+ maxKeys: number;
48
+ /** Serialise a key. Two keys with the same string share one entry. */
49
+ keyOf: (key: K) => string;
50
+ /**
51
+ * Where a key folds to once the map is full. Should collapse the
52
+ * high-cardinality parts of the key (a source address, a route) while
53
+ * keeping the attribution the consumer cannot afford to lose.
54
+ */
55
+ overflowKey: (key: K) => K;
56
+ }
57
+ export declare class BoundedCounter<K> {
58
+ private readonly entries;
59
+ private overflowedSinceDrain;
60
+ private readonly maxKeys;
61
+ private readonly keyOf;
62
+ private readonly overflowKey;
63
+ constructor(opts: BoundedCounterOptions<K>);
64
+ /** Distinct keys currently held. */
65
+ get size(): number;
66
+ /** Increments folded into an overflow key since the last drain. */
67
+ get overflowed(): number;
68
+ /**
69
+ * Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
70
+ * `keyOf`/`overflowKey`; the caller's request path must not depend on it in
71
+ * any other way.
72
+ */
73
+ increment(key: K, by?: number, at?: number): void;
74
+ /**
75
+ * Return every entry and start a fresh window. The returned array is the
76
+ * only reference to those entries, so a slow sink never contends with the
77
+ * hot path.
78
+ */
79
+ drain(): CounterEntry<K>[];
80
+ }
81
+ export type CounterSink<K> = (entries: CounterEntry<K>[]) => Promise<void>;
82
+ export interface CounterFlusherOptions<K> {
83
+ /** Base time between flushes. */
84
+ intervalMs: number;
85
+ /**
86
+ * Uniform random offset added to each interval so replicas started together
87
+ * do not hit the store on the same tick.
88
+ */
89
+ jitterMs: number;
90
+ /**
91
+ * Called for every failed flush with the batch that was dropped. Every
92
+ * failure is reported — there is no "log once then go quiet" — and the
93
+ * request path is not involved, so making this loud is free.
94
+ */
95
+ onError: (err: unknown, dropped: CounterEntry<K>[]) => void;
96
+ }
97
+ export interface CounterFlusherStats {
98
+ /** Epoch ms of the last flush attempt that returned without error, or null. */
99
+ lastFlushAt: number | null;
100
+ /** Epoch ms of the last failed flush, or null. */
101
+ lastErrorAt: number | null;
102
+ /** Message of the last failed flush, or null. */
103
+ lastError: string | null;
104
+ /** Failed flushes since the last success. */
105
+ consecutiveFailures: number;
106
+ /** Entries handed to `onError` over the flusher's lifetime. */
107
+ droppedEntries: number;
108
+ /** Whether the timer is armed. */
109
+ running: boolean;
110
+ }
111
+ export declare class CounterFlusher<K> {
112
+ private readonly counter;
113
+ private readonly sink;
114
+ private readonly opts;
115
+ private timer;
116
+ private inFlight;
117
+ private running;
118
+ private lastFlushAt;
119
+ private lastErrorAt;
120
+ private lastError;
121
+ private consecutiveFailures;
122
+ private droppedEntries;
123
+ constructor(counter: BoundedCounter<K>, sink: CounterSink<K>, opts: CounterFlusherOptions<K>);
124
+ /** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
125
+ start(): void;
126
+ /** Disarm the timer and flush whatever is pending. Idempotent. */
127
+ stop(): Promise<void>;
128
+ /**
129
+ * Drain and write now. Never rejects: a sink failure goes to `onError`. If a
130
+ * flush is already in flight the call waits for it and then runs its own, so
131
+ * two overlapping timers cannot send batches out of order.
132
+ */
133
+ flush(): Promise<void>;
134
+ stats(): CounterFlusherStats;
135
+ private arm;
136
+ private flushOnce;
137
+ }