@constructive-io/express-context 0.26.2 → 0.28.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * refusals — the refusal-observability instantiation of `usage-counter`.
3
+ *
4
+ * Every refusal the platform issues (a 429 from admission control, a 413 for an
5
+ * oversized body, a document-gate rejection, a gateway's route or IP window)
6
+ * is one `RefusalRecorder.record(...)` call at the point the response is
7
+ * written. That call is an in-memory increment and nothing else — no promise,
8
+ * no pool, no allocation beyond the key — so a flood costs a counter bump and
9
+ * refusing traffic keeps working when the recorder's store is down.
10
+ *
11
+ * Counts are keyed by `(minute, database, lane, reason, route, source /24)`,
12
+ * which is exactly the set of questions the platform table answers: which
13
+ * tenant, why, on which route, from which network, since when. Raw addresses
14
+ * are never part of the key; `sourceBucket` truncates to a /24 (v4) or /48
15
+ * (v6) before anything is counted.
16
+ *
17
+ * The flush writes the drained batch as one `record_refusals(jsonb)` call —
18
+ * one statement per window regardless of how many keys it holds.
19
+ *
20
+ * @module refusals
21
+ */
22
+ import { Logger } from '@pgpmjs/logger';
23
+ import { withPgClient } from 'pg-query-context';
24
+ import { BoundedCounter, CounterFlusher } from './usage-counter';
25
+ const log = new Logger('refusals');
26
+ export const REFUSAL_REASONS = [
27
+ 'rate_limited',
28
+ 'concurrency_saturated',
29
+ 'queue_timeout',
30
+ 'request_too_large',
31
+ 'query_too_deep',
32
+ 'query_too_costly',
33
+ 'page_size_too_large',
34
+ 'anonymous_not_callable',
35
+ 'route_rate_limited'
36
+ ];
37
+ /** `source_bucket` when the lane could not resolve an address. */
38
+ export const UNKNOWN_SOURCE = 'unknown';
39
+ /** `source_bucket` / `route_key` of the fold-down entry once the counter is full. */
40
+ export const OVERFLOW_SOURCE = 'overflow';
41
+ export const OVERFLOW_ROUTE = '*';
42
+ const MINUTE_MS = 60_000;
43
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
44
+ /**
45
+ * Reduce a client address to a network prefix: `/24` for IPv4, `/48` for IPv6.
46
+ * Enough to tell "one network hammering an anonymous route" from "the tenant's
47
+ * own users"; not enough to be a person. Anything unparseable is `unknown`.
48
+ */
49
+ export const sourceBucket = (ip) => {
50
+ if (!ip)
51
+ return UNKNOWN_SOURCE;
52
+ let addr = ip.trim();
53
+ if (addr.startsWith('[')) {
54
+ const end = addr.indexOf(']');
55
+ if (end === -1)
56
+ return UNKNOWN_SOURCE;
57
+ addr = addr.slice(1, end);
58
+ }
59
+ const zone = addr.indexOf('%');
60
+ if (zone !== -1)
61
+ addr = addr.slice(0, zone);
62
+ if (addr.toLowerCase().startsWith('::ffff:')) {
63
+ const mapped = addr.slice('::ffff:'.length);
64
+ if (IPV4.test(mapped))
65
+ addr = mapped;
66
+ }
67
+ const v4 = IPV4.exec(addr);
68
+ if (v4) {
69
+ const octets = v4.slice(1, 5).map(Number);
70
+ if (octets.some((o) => o > 255))
71
+ return UNKNOWN_SOURCE;
72
+ return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
73
+ }
74
+ const groups = expandIpv6(addr);
75
+ if (!groups)
76
+ return UNKNOWN_SOURCE;
77
+ return `${groups[0]}:${groups[1]}:${groups[2]}::/48`;
78
+ };
79
+ /** The eight hextets of an IPv6 address, or null when it does not parse. */
80
+ const expandIpv6 = (addr) => {
81
+ if (!/^[0-9a-fA-F:]+$/.test(addr) || addr.indexOf(':::') !== -1)
82
+ return null;
83
+ const halves = addr.split('::');
84
+ if (halves.length > 2)
85
+ return null;
86
+ const head = halves[0] ? halves[0].split(':') : [];
87
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
88
+ const missing = 8 - head.length - tail.length;
89
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
90
+ return null;
91
+ const groups = [...head, ...Array(missing).fill('0'), ...tail];
92
+ if (groups.some((g) => g.length === 0 || g.length > 4))
93
+ return null;
94
+ return groups.map((g) => parseInt(g, 16).toString(16));
95
+ };
96
+ const minuteOf = (at) => Math.floor(at / MINUTE_MS) * MINUTE_MS;
97
+ export const refusalKeyOf = (key) => `${key.minuteBucket}\u0000${key.databaseId ?? ''}\u0000${key.lane}\u0000${key.reason}\u0000${key.routeKey}\u0000${key.sourceBucket}`;
98
+ /**
99
+ * Where a key folds to once the counter is full: same minute, tenant, lane and
100
+ * reason; route and source collapsed. A source-spraying flood therefore costs
101
+ * `maxKeys` entries plus one per `(database, lane, reason)`, the tenant/reason
102
+ * attribution is never lost, and the presence of an `overflow` row is itself
103
+ * the signal that a spray happened.
104
+ */
105
+ export const refusalOverflowKey = (key) => ({
106
+ ...key,
107
+ routeKey: OVERFLOW_ROUTE,
108
+ sourceBucket: OVERFLOW_SOURCE
109
+ });
110
+ /** Shape a drained batch for `record_refusals(jsonb)`. */
111
+ export const refusalRows = (entries) => entries.map(({ key, count, firstAt, lastAt }) => ({
112
+ minute_bucket: new Date(key.minuteBucket).toISOString(),
113
+ database_id: key.databaseId,
114
+ lane: key.lane,
115
+ reason: key.reason,
116
+ route_key: key.routeKey,
117
+ source_bucket: key.sourceBucket,
118
+ count,
119
+ first_seen_at: new Date(firstAt).toISOString(),
120
+ last_seen_at: new Date(lastAt).toISOString()
121
+ }));
122
+ export const DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = 10_000;
123
+ export const DEFAULT_REFUSAL_FLUSH_JITTER_MS = 2_000;
124
+ export const DEFAULT_REFUSAL_MAX_KEYS = 2_000;
125
+ const defaultOnError = (err, dropped) => {
126
+ const refusals = dropped.reduce((sum, e) => sum + e.count, 0);
127
+ const message = err instanceof Error ? err.message : String(err);
128
+ log.error(`[refusals] flush failed; dropped ${refusals} refusals across ${dropped.length} keys: ${message}`);
129
+ };
130
+ /**
131
+ * `BoundedCounter<RefusalKey>` + `CounterFlusher` + a `record_refusals` sink.
132
+ * Construct one per process, `start()` it, `record()` from every refusal site,
133
+ * `stop()` on shutdown.
134
+ */
135
+ export class RefusalRecorder {
136
+ counter;
137
+ flusher;
138
+ recordFailures = 0;
139
+ constructor(opts) {
140
+ this.counter = new BoundedCounter({
141
+ maxKeys: opts.maxKeys ?? DEFAULT_REFUSAL_MAX_KEYS,
142
+ keyOf: refusalKeyOf,
143
+ overflowKey: refusalOverflowKey
144
+ });
145
+ this.flusher = new CounterFlusher(this.counter, (entries) => opts.sink(refusalRows(entries)), {
146
+ intervalMs: opts.intervalMs ?? DEFAULT_REFUSAL_FLUSH_INTERVAL_MS,
147
+ jitterMs: opts.jitterMs ?? DEFAULT_REFUSAL_FLUSH_JITTER_MS,
148
+ onError: opts.onError ?? defaultOnError
149
+ });
150
+ }
151
+ /**
152
+ * Count one refusal. Synchronous and never throws: the request that is
153
+ * being refused must finish being refused whatever state the recorder is in.
154
+ */
155
+ record(refusal) {
156
+ try {
157
+ const at = refusal.at ?? Date.now();
158
+ this.counter.increment({
159
+ minuteBucket: minuteOf(at),
160
+ databaseId: refusal.databaseId,
161
+ lane: refusal.lane,
162
+ reason: refusal.reason,
163
+ routeKey: refusal.routeKey,
164
+ sourceBucket: sourceBucket(refusal.sourceIp)
165
+ }, 1, at);
166
+ }
167
+ catch (err) {
168
+ this.recordFailures += 1;
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ log.error(`[refusals] record failed (refusal still served): ${message}`);
171
+ }
172
+ }
173
+ start() {
174
+ this.flusher.start();
175
+ }
176
+ /** Final flush, then stop. */
177
+ stop() {
178
+ return this.flusher.stop();
179
+ }
180
+ /** Drain and write now; a sink failure is reported, never thrown. */
181
+ flush() {
182
+ return this.flusher.flush();
183
+ }
184
+ stats() {
185
+ return {
186
+ keys: this.counter.size,
187
+ overflowed: this.counter.overflowed,
188
+ recordFailures: this.recordFailures,
189
+ flusher: this.flusher.stats()
190
+ };
191
+ }
192
+ }
193
+ const FUNCTION_NAME = /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/;
194
+ /**
195
+ * A sink that writes a batch as one `record_refusals(jsonb)` call inside a
196
+ * transaction carrying the caller's claims. Claims are resolved per flush so a
197
+ * pool that was unreachable at startup does not leave the recorder wedged.
198
+ */
199
+ export const createRecordRefusalsSink = (opts) => {
200
+ const fn = opts.functionName ?? 'constructive_usage_private.record_refusals';
201
+ if (!FUNCTION_NAME.test(fn)) {
202
+ throw new Error(`createRecordRefusalsSink: invalid function name ${JSON.stringify(fn)}`);
203
+ }
204
+ const [schema, name] = fn.split('.');
205
+ const sql = `SELECT "${schema}"."${name}"($1::jsonb) AS recorded`;
206
+ return async (rows) => {
207
+ if (rows.length === 0)
208
+ return;
209
+ const claims = await opts.claims();
210
+ await withPgClient(opts.pool, claims, async (client) => {
211
+ await client.query(sql, [JSON.stringify(rows)]);
212
+ });
213
+ };
214
+ };
@@ -0,0 +1,190 @@
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 class BoundedCounter {
31
+ entries = new Map();
32
+ overflowedSinceDrain = 0;
33
+ maxKeys;
34
+ keyOf;
35
+ overflowKey;
36
+ constructor(opts) {
37
+ if (!Number.isInteger(opts.maxKeys) || opts.maxKeys < 1) {
38
+ throw new Error(`BoundedCounter: maxKeys must be a positive integer, got ${opts.maxKeys}`);
39
+ }
40
+ this.maxKeys = opts.maxKeys;
41
+ this.keyOf = opts.keyOf;
42
+ this.overflowKey = opts.overflowKey;
43
+ }
44
+ /** Distinct keys currently held. */
45
+ get size() {
46
+ return this.entries.size;
47
+ }
48
+ /** Increments folded into an overflow key since the last drain. */
49
+ get overflowed() {
50
+ return this.overflowedSinceDrain;
51
+ }
52
+ /**
53
+ * Add `by` to `key`. Synchronous, O(1), never throws for a well-formed
54
+ * `keyOf`/`overflowKey`; the caller's request path must not depend on it in
55
+ * any other way.
56
+ */
57
+ increment(key, by = 1, at = Date.now()) {
58
+ let id = this.keyOf(key);
59
+ let entry = this.entries.get(id);
60
+ if (!entry && this.entries.size >= this.maxKeys) {
61
+ key = this.overflowKey(key);
62
+ id = this.keyOf(key);
63
+ entry = this.entries.get(id);
64
+ this.overflowedSinceDrain += by;
65
+ }
66
+ if (!entry) {
67
+ this.entries.set(id, { key, count: by, firstAt: at, lastAt: at });
68
+ return;
69
+ }
70
+ entry.count += by;
71
+ if (at < entry.firstAt)
72
+ entry.firstAt = at;
73
+ if (at > entry.lastAt)
74
+ entry.lastAt = at;
75
+ }
76
+ /**
77
+ * Return every entry and start a fresh window. The returned array is the
78
+ * only reference to those entries, so a slow sink never contends with the
79
+ * hot path.
80
+ */
81
+ drain() {
82
+ const drained = Array.from(this.entries.values());
83
+ this.entries.clear();
84
+ this.overflowedSinceDrain = 0;
85
+ return drained;
86
+ }
87
+ }
88
+ export class CounterFlusher {
89
+ counter;
90
+ sink;
91
+ opts;
92
+ timer = null;
93
+ inFlight = null;
94
+ running = false;
95
+ lastFlushAt = null;
96
+ lastErrorAt = null;
97
+ lastError = null;
98
+ consecutiveFailures = 0;
99
+ droppedEntries = 0;
100
+ constructor(counter, sink, opts) {
101
+ this.counter = counter;
102
+ this.sink = sink;
103
+ this.opts = opts;
104
+ if (!(opts.intervalMs > 0)) {
105
+ throw new Error(`CounterFlusher: intervalMs must be positive, got ${opts.intervalMs}`);
106
+ }
107
+ if (!(opts.jitterMs >= 0)) {
108
+ throw new Error(`CounterFlusher: jitterMs must be non-negative, got ${opts.jitterMs}`);
109
+ }
110
+ }
111
+ /** Arm the timer. Idempotent. The timer is unref'd so it never pins the process. */
112
+ start() {
113
+ if (this.running)
114
+ return;
115
+ this.running = true;
116
+ this.arm();
117
+ }
118
+ /** Disarm the timer and flush whatever is pending. Idempotent. */
119
+ async stop() {
120
+ this.running = false;
121
+ if (this.timer) {
122
+ clearTimeout(this.timer);
123
+ this.timer = null;
124
+ }
125
+ await this.flush();
126
+ }
127
+ /**
128
+ * Drain and write now. Never rejects: a sink failure goes to `onError`. If a
129
+ * flush is already in flight the call waits for it and then runs its own, so
130
+ * two overlapping timers cannot send batches out of order.
131
+ */
132
+ async flush() {
133
+ if (this.inFlight)
134
+ await this.inFlight;
135
+ const run = this.flushOnce();
136
+ this.inFlight = run;
137
+ try {
138
+ await run;
139
+ }
140
+ finally {
141
+ if (this.inFlight === run)
142
+ this.inFlight = null;
143
+ }
144
+ }
145
+ stats() {
146
+ return {
147
+ lastFlushAt: this.lastFlushAt,
148
+ lastErrorAt: this.lastErrorAt,
149
+ lastError: this.lastError,
150
+ consecutiveFailures: this.consecutiveFailures,
151
+ droppedEntries: this.droppedEntries,
152
+ running: this.running
153
+ };
154
+ }
155
+ arm() {
156
+ const delay = this.opts.intervalMs + Math.floor(Math.random() * (this.opts.jitterMs + 1));
157
+ this.timer = setTimeout(() => {
158
+ this.timer = null;
159
+ void this.flush().finally(() => {
160
+ if (this.running)
161
+ this.arm();
162
+ });
163
+ }, delay);
164
+ this.timer.unref?.();
165
+ }
166
+ async flushOnce() {
167
+ const entries = this.counter.drain();
168
+ if (entries.length === 0)
169
+ return;
170
+ try {
171
+ await this.sink(entries);
172
+ this.lastFlushAt = Date.now();
173
+ this.consecutiveFailures = 0;
174
+ }
175
+ catch (err) {
176
+ this.lastErrorAt = Date.now();
177
+ this.lastError = err instanceof Error ? err.message : String(err);
178
+ this.consecutiveFailures += 1;
179
+ this.droppedEntries += entries.length;
180
+ // The batch is gone whether or not onError itself misbehaves; a
181
+ // reporter that throws must not turn one lost window into a stuck loop.
182
+ try {
183
+ this.opts.onError(err, entries);
184
+ }
185
+ catch {
186
+ // Fire-and-forget by design: the failure is already recorded in stats().
187
+ }
188
+ }
189
+ }
190
+ }
package/index.d.ts CHANGED
@@ -42,6 +42,12 @@ export { buildPgSettings } from './pg-settings';
42
42
  export { withPgClient } from './pg-client';
43
43
  export type { NumericProtectionKey, ProtectionBound, RequestProtection, RequestProtectionInput, } from './request-protection';
44
44
  export { ASSUMED_PAGE_SIZE, DEFAULT_REQUEST_PROTECTION, INTROSPECTION_BOUND, PROTECTION_BOUNDS, protectionPgSettings, resolveRequestProtection, } from './request-protection';
45
+ export type { AdmissionLease, AdmissionRefusal, AdmissionRequest } from './admission';
46
+ export { clientIpFrom, ConcurrencyLimiter, RateWindow, trustedProxyHops } from './admission';
47
+ export type { RecordRefusalsSinkOptions, Refusal, RefusalKey, RefusalLane, RefusalReason, RefusalRecorderOptions, RefusalRecorderStats, RefusalRow, RefusalSink } from './refusals';
48
+ export { createRecordRefusalsSink, DEFAULT_REFUSAL_FLUSH_INTERVAL_MS, DEFAULT_REFUSAL_FLUSH_JITTER_MS, DEFAULT_REFUSAL_MAX_KEYS, OVERFLOW_ROUTE, OVERFLOW_SOURCE, REFUSAL_REASONS, refusalKeyOf, refusalOverflowKey, RefusalRecorder, refusalRows, sourceBucket, UNKNOWN_SOURCE } from './refusals';
49
+ export type { BoundedCounterOptions, CounterEntry, CounterFlusherOptions, CounterFlusherStats, CounterSink } from './usage-counter';
50
+ export { BoundedCounter, CounterFlusher } from './usage-counter';
45
51
  export { requestIdMiddleware } from './request-id';
46
52
  export type { ContextMiddlewareOptions } from './context';
47
53
  export { buildContext, createContextMiddleware } from './context';
package/index.js CHANGED
@@ -36,7 +36,7 @@
36
36
  * ```
37
37
  */
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.webauthnLoader = exports.rlsLoader = exports.requireIdentityProvider = exports.requireDatabaseId = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.identityProvidersLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.resolveRequestProtection = exports.protectionPgSettings = exports.PROTECTION_BOUNDS = exports.INTROSPECTION_BOUND = exports.DEFAULT_REQUEST_PROTECTION = exports.ASSUMED_PAGE_SIZE = exports.withPgClient = exports.buildPgSettings = exports.createBillingClient = void 0;
39
+ exports.webauthnLoader = exports.rlsLoader = exports.requireIdentityProvider = exports.requireDatabaseId = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.identityProvidersLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.CounterFlusher = exports.BoundedCounter = exports.UNKNOWN_SOURCE = exports.sourceBucket = exports.refusalRows = exports.RefusalRecorder = exports.refusalOverflowKey = exports.refusalKeyOf = exports.REFUSAL_REASONS = exports.OVERFLOW_SOURCE = exports.OVERFLOW_ROUTE = exports.DEFAULT_REFUSAL_MAX_KEYS = exports.DEFAULT_REFUSAL_FLUSH_JITTER_MS = exports.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = exports.createRecordRefusalsSink = exports.trustedProxyHops = exports.RateWindow = exports.ConcurrencyLimiter = exports.clientIpFrom = exports.resolveRequestProtection = exports.protectionPgSettings = exports.PROTECTION_BOUNDS = exports.INTROSPECTION_BOUND = exports.DEFAULT_REQUEST_PROTECTION = exports.ASSUMED_PAGE_SIZE = exports.withPgClient = exports.buildPgSettings = exports.createBillingClient = void 0;
40
40
  var billing_client_1 = require("./billing-client");
41
41
  Object.defineProperty(exports, "createBillingClient", { enumerable: true, get: function () { return billing_client_1.createBillingClient; } });
42
42
  var pg_settings_1 = require("./pg-settings");
@@ -51,6 +51,28 @@ Object.defineProperty(exports, "INTROSPECTION_BOUND", { enumerable: true, get: f
51
51
  Object.defineProperty(exports, "PROTECTION_BOUNDS", { enumerable: true, get: function () { return request_protection_1.PROTECTION_BOUNDS; } });
52
52
  Object.defineProperty(exports, "protectionPgSettings", { enumerable: true, get: function () { return request_protection_1.protectionPgSettings; } });
53
53
  Object.defineProperty(exports, "resolveRequestProtection", { enumerable: true, get: function () { return request_protection_1.resolveRequestProtection; } });
54
+ var admission_1 = require("./admission");
55
+ Object.defineProperty(exports, "clientIpFrom", { enumerable: true, get: function () { return admission_1.clientIpFrom; } });
56
+ Object.defineProperty(exports, "ConcurrencyLimiter", { enumerable: true, get: function () { return admission_1.ConcurrencyLimiter; } });
57
+ Object.defineProperty(exports, "RateWindow", { enumerable: true, get: function () { return admission_1.RateWindow; } });
58
+ Object.defineProperty(exports, "trustedProxyHops", { enumerable: true, get: function () { return admission_1.trustedProxyHops; } });
59
+ var refusals_1 = require("./refusals");
60
+ Object.defineProperty(exports, "createRecordRefusalsSink", { enumerable: true, get: function () { return refusals_1.createRecordRefusalsSink; } });
61
+ Object.defineProperty(exports, "DEFAULT_REFUSAL_FLUSH_INTERVAL_MS", { enumerable: true, get: function () { return refusals_1.DEFAULT_REFUSAL_FLUSH_INTERVAL_MS; } });
62
+ Object.defineProperty(exports, "DEFAULT_REFUSAL_FLUSH_JITTER_MS", { enumerable: true, get: function () { return refusals_1.DEFAULT_REFUSAL_FLUSH_JITTER_MS; } });
63
+ Object.defineProperty(exports, "DEFAULT_REFUSAL_MAX_KEYS", { enumerable: true, get: function () { return refusals_1.DEFAULT_REFUSAL_MAX_KEYS; } });
64
+ Object.defineProperty(exports, "OVERFLOW_ROUTE", { enumerable: true, get: function () { return refusals_1.OVERFLOW_ROUTE; } });
65
+ Object.defineProperty(exports, "OVERFLOW_SOURCE", { enumerable: true, get: function () { return refusals_1.OVERFLOW_SOURCE; } });
66
+ Object.defineProperty(exports, "REFUSAL_REASONS", { enumerable: true, get: function () { return refusals_1.REFUSAL_REASONS; } });
67
+ Object.defineProperty(exports, "refusalKeyOf", { enumerable: true, get: function () { return refusals_1.refusalKeyOf; } });
68
+ Object.defineProperty(exports, "refusalOverflowKey", { enumerable: true, get: function () { return refusals_1.refusalOverflowKey; } });
69
+ Object.defineProperty(exports, "RefusalRecorder", { enumerable: true, get: function () { return refusals_1.RefusalRecorder; } });
70
+ Object.defineProperty(exports, "refusalRows", { enumerable: true, get: function () { return refusals_1.refusalRows; } });
71
+ Object.defineProperty(exports, "sourceBucket", { enumerable: true, get: function () { return refusals_1.sourceBucket; } });
72
+ Object.defineProperty(exports, "UNKNOWN_SOURCE", { enumerable: true, get: function () { return refusals_1.UNKNOWN_SOURCE; } });
73
+ var usage_counter_1 = require("./usage-counter");
74
+ Object.defineProperty(exports, "BoundedCounter", { enumerable: true, get: function () { return usage_counter_1.BoundedCounter; } });
75
+ Object.defineProperty(exports, "CounterFlusher", { enumerable: true, get: function () { return usage_counter_1.CounterFlusher; } });
54
76
  // Request ID middleware
55
77
  var request_id_1 = require("./request-id");
56
78
  Object.defineProperty(exports, "requestIdMiddleware", { enumerable: true, get: function () { return request_id_1.requestIdMiddleware; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/express-context",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
4
4
  "description": "Extractable Express middleware for Constructive tenant context — domain resolution, JWT auth, pgSettings, and withPgClient",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -30,13 +30,13 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@constructive-io/url-domains": "^2.30.1",
33
- "@pgpmjs/env": "^2.43.2",
33
+ "@pgpmjs/env": "^2.43.4",
34
34
  "@pgpmjs/logger": "^2.25.1",
35
- "@pgpmjs/server-utils": "^3.27.2",
36
- "@pgpmjs/types": "^2.53.0",
35
+ "@pgpmjs/server-utils": "^3.27.4",
36
+ "@pgpmjs/types": "^2.53.2",
37
37
  "lru-cache": "^11.2.7",
38
38
  "pg": "^8.21.0",
39
- "pg-cache": "^3.27.2",
39
+ "pg-cache": "^3.27.4",
40
40
  "pg-query-context": "^2.30.1"
41
41
  },
42
42
  "devDependencies": {
@@ -59,5 +59,5 @@
59
59
  "jwt",
60
60
  "pgSettings"
61
61
  ],
62
- "gitHead": "5e0bfead2856d1a062b495d343a43e543343f9b8"
62
+ "gitHead": "01e073830677c0587bab564b7d575a06b5156f0c"
63
63
  }
package/pg-settings.js CHANGED
@@ -33,6 +33,17 @@ function buildPgSettings(input) {
33
33
  if (token?.session_id) {
34
34
  settings['jwt.claims.session_id'] = token.session_id;
35
35
  }
36
+ // Session lineage (token exchange chains)
37
+ if (token?.root_session_id) {
38
+ settings['jwt.claims.root_session_id'] = token.root_session_id;
39
+ }
40
+ if (token?.parent_session_id) {
41
+ settings['jwt.claims.parent_session_id'] = token.parent_session_id;
42
+ }
43
+ // Declared purpose of the credential
44
+ if (token?.intent) {
45
+ settings['jwt.claims.intent'] = token.intent;
46
+ }
36
47
  // Principal identity (service accounts / bots)
37
48
  if (token?.principal_id) {
38
49
  settings['jwt.claims.principal_id'] = token.principal_id;
package/refusals.d.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * refusals — the refusal-observability instantiation of `usage-counter`.
3
+ *
4
+ * Every refusal the platform issues (a 429 from admission control, a 413 for an
5
+ * oversized body, a document-gate rejection, a gateway's route or IP window)
6
+ * is one `RefusalRecorder.record(...)` call at the point the response is
7
+ * written. That call is an in-memory increment and nothing else — no promise,
8
+ * no pool, no allocation beyond the key — so a flood costs a counter bump and
9
+ * refusing traffic keeps working when the recorder's store is down.
10
+ *
11
+ * Counts are keyed by `(minute, database, lane, reason, route, source /24)`,
12
+ * which is exactly the set of questions the platform table answers: which
13
+ * tenant, why, on which route, from which network, since when. Raw addresses
14
+ * are never part of the key; `sourceBucket` truncates to a /24 (v4) or /48
15
+ * (v6) before anything is counted.
16
+ *
17
+ * The flush writes the drained batch as one `record_refusals(jsonb)` call —
18
+ * one statement per window regardless of how many keys it holds.
19
+ *
20
+ * @module refusals
21
+ */
22
+ import type { Pool } from 'pg';
23
+ import type { CounterEntry, CounterFlusherStats } from './usage-counter';
24
+ /** Which platform surface refused the request. */
25
+ export type RefusalLane = 'graphql' | 'sync' | 'static';
26
+ /**
27
+ * Why a request was refused. Text in the table rather than an enum, so a new
28
+ * reason is a code change and not a migration; this union is the one list.
29
+ */
30
+ export type RefusalReason = 'rate_limited' | 'concurrency_saturated' | 'queue_timeout' | 'request_too_large' | 'query_too_deep' | 'query_too_costly' | 'page_size_too_large' | 'anonymous_not_callable' | 'route_rate_limited';
31
+ export declare const REFUSAL_REASONS: readonly RefusalReason[];
32
+ /** One refused request, as the emitter sees it. */
33
+ export interface Refusal {
34
+ /** The tenant refused, or null when the request never resolved one. */
35
+ databaseId: string | null;
36
+ lane: RefusalLane;
37
+ reason: RefusalReason;
38
+ /** `"POST /graphql"`, a route binding id, `host+path` — what the lane keys on. */
39
+ routeKey: string;
40
+ /** The client address as the lane resolved it. Bucketed before it is counted. */
41
+ sourceIp: string | null | undefined;
42
+ /** Epoch ms; defaults to now. */
43
+ at?: number;
44
+ }
45
+ /** The aggregation key — one `refusal_log` row per distinct value per window. */
46
+ export interface RefusalKey {
47
+ /** Epoch ms, truncated to the minute. */
48
+ minuteBucket: number;
49
+ databaseId: string | null;
50
+ lane: RefusalLane;
51
+ reason: RefusalReason;
52
+ routeKey: string;
53
+ sourceBucket: string;
54
+ }
55
+ /** `source_bucket` when the lane could not resolve an address. */
56
+ export declare const UNKNOWN_SOURCE = "unknown";
57
+ /** `source_bucket` / `route_key` of the fold-down entry once the counter is full. */
58
+ export declare const OVERFLOW_SOURCE = "overflow";
59
+ export declare const OVERFLOW_ROUTE = "*";
60
+ /** A row as `constructive_usage_private.record_refusals(jsonb)` reads it. */
61
+ export interface RefusalRow {
62
+ minute_bucket: string;
63
+ database_id: string | null;
64
+ lane: RefusalLane;
65
+ reason: RefusalReason;
66
+ route_key: string;
67
+ source_bucket: string;
68
+ count: number;
69
+ first_seen_at: string;
70
+ last_seen_at: string;
71
+ }
72
+ export type RefusalSink = (rows: RefusalRow[]) => Promise<void>;
73
+ /**
74
+ * Reduce a client address to a network prefix: `/24` for IPv4, `/48` for IPv6.
75
+ * Enough to tell "one network hammering an anonymous route" from "the tenant's
76
+ * own users"; not enough to be a person. Anything unparseable is `unknown`.
77
+ */
78
+ export declare const sourceBucket: (ip: string | null | undefined) => string;
79
+ export declare const refusalKeyOf: (key: RefusalKey) => string;
80
+ /**
81
+ * Where a key folds to once the counter is full: same minute, tenant, lane and
82
+ * reason; route and source collapsed. A source-spraying flood therefore costs
83
+ * `maxKeys` entries plus one per `(database, lane, reason)`, the tenant/reason
84
+ * attribution is never lost, and the presence of an `overflow` row is itself
85
+ * the signal that a spray happened.
86
+ */
87
+ export declare const refusalOverflowKey: (key: RefusalKey) => RefusalKey;
88
+ /** Shape a drained batch for `record_refusals(jsonb)`. */
89
+ export declare const refusalRows: (entries: CounterEntry<RefusalKey>[]) => RefusalRow[];
90
+ export interface RefusalRecorderOptions {
91
+ /** Where a drained batch goes. See `createRecordRefusalsSink`. */
92
+ sink: RefusalSink;
93
+ /** Base flush interval. Default 10 s. */
94
+ intervalMs?: number;
95
+ /** Jitter added to each interval. Default 2 s. */
96
+ jitterMs?: number;
97
+ /** Distinct keys held per window before folding. Default 2,000. */
98
+ maxKeys?: number;
99
+ /**
100
+ * Failure reporter. Defaults to an error log naming the SQL error and how
101
+ * many refusals were dropped; every failed flush is reported.
102
+ */
103
+ onError?: (err: unknown, dropped: CounterEntry<RefusalKey>[]) => void;
104
+ }
105
+ export declare const DEFAULT_REFUSAL_FLUSH_INTERVAL_MS = 10000;
106
+ export declare const DEFAULT_REFUSAL_FLUSH_JITTER_MS = 2000;
107
+ export declare const DEFAULT_REFUSAL_MAX_KEYS = 2000;
108
+ export interface RefusalRecorderStats {
109
+ keys: number;
110
+ overflowed: number;
111
+ recordFailures: number;
112
+ flusher: CounterFlusherStats;
113
+ }
114
+ /**
115
+ * `BoundedCounter<RefusalKey>` + `CounterFlusher` + a `record_refusals` sink.
116
+ * Construct one per process, `start()` it, `record()` from every refusal site,
117
+ * `stop()` on shutdown.
118
+ */
119
+ export declare class RefusalRecorder {
120
+ private readonly counter;
121
+ private readonly flusher;
122
+ private recordFailures;
123
+ constructor(opts: RefusalRecorderOptions);
124
+ /**
125
+ * Count one refusal. Synchronous and never throws: the request that is
126
+ * being refused must finish being refused whatever state the recorder is in.
127
+ */
128
+ record(refusal: Refusal): void;
129
+ start(): void;
130
+ /** Final flush, then stop. */
131
+ stop(): Promise<void>;
132
+ /** Drain and write now; a sink failure is reported, never thrown. */
133
+ flush(): Promise<void>;
134
+ stats(): RefusalRecorderStats;
135
+ }
136
+ export interface RecordRefusalsSinkOptions {
137
+ /** The platform database's pool. */
138
+ pool: Pool;
139
+ /**
140
+ * The transaction-local claims the flush runs under, resolved by the caller
141
+ * (a service principal plus the platform database id). The function raises
142
+ * if `jwt.claims.user_id` is absent, so a resolver that returns nothing
143
+ * useful fails loudly at the first flush rather than writing unattributed.
144
+ */
145
+ claims: () => Promise<Record<string, string>>;
146
+ /** Default `constructive_usage_private.record_refusals`. */
147
+ functionName?: string;
148
+ }
149
+ /**
150
+ * A sink that writes a batch as one `record_refusals(jsonb)` call inside a
151
+ * transaction carrying the caller's claims. Claims are resolved per flush so a
152
+ * pool that was unreachable at startup does not leave the recorder wedged.
153
+ */
154
+ export declare const createRecordRefusalsSink: (opts: RecordRefusalsSinkOptions) => RefusalSink;