@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/admission.d.ts +146 -0
- package/admission.js +249 -0
- package/esm/admission.js +242 -0
- package/esm/index.js +5 -1
- package/esm/loaders/index.js +4 -0
- package/esm/loaders/request-protection.js +146 -0
- package/esm/refusals.js +214 -0
- package/esm/request-protection.js +106 -0
- package/esm/usage-counter.js +190 -0
- package/index.d.ts +9 -1
- package/index.js +31 -1
- package/loaders/index.d.ts +2 -0
- package/loaders/index.js +6 -1
- package/loaders/request-protection.d.ts +22 -0
- package/loaders/request-protection.js +150 -0
- package/package.json +10 -10
- package/refusals.d.ts +154 -0
- package/refusals.js +223 -0
- package/request-protection.d.ts +117 -0
- package/request-protection.js +111 -0
- package/types.d.ts +2 -0
- package/usage-counter.d.ts +137 -0
- package/usage-counter.js +195 -0
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.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.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");
|
|
@@ -44,6 +44,35 @@ Object.defineProperty(exports, "buildPgSettings", { enumerable: true, get: funct
|
|
|
44
44
|
// withPgClient helper
|
|
45
45
|
var pg_client_1 = require("./pg-client");
|
|
46
46
|
Object.defineProperty(exports, "withPgClient", { enumerable: true, get: function () { return pg_client_1.withPgClient; } });
|
|
47
|
+
var request_protection_1 = require("./request-protection");
|
|
48
|
+
Object.defineProperty(exports, "ASSUMED_PAGE_SIZE", { enumerable: true, get: function () { return request_protection_1.ASSUMED_PAGE_SIZE; } });
|
|
49
|
+
Object.defineProperty(exports, "DEFAULT_REQUEST_PROTECTION", { enumerable: true, get: function () { return request_protection_1.DEFAULT_REQUEST_PROTECTION; } });
|
|
50
|
+
Object.defineProperty(exports, "INTROSPECTION_BOUND", { enumerable: true, get: function () { return request_protection_1.INTROSPECTION_BOUND; } });
|
|
51
|
+
Object.defineProperty(exports, "PROTECTION_BOUNDS", { enumerable: true, get: function () { return request_protection_1.PROTECTION_BOUNDS; } });
|
|
52
|
+
Object.defineProperty(exports, "protectionPgSettings", { enumerable: true, get: function () { return request_protection_1.protectionPgSettings; } });
|
|
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; } });
|
|
47
76
|
// Request ID middleware
|
|
48
77
|
var request_id_1 = require("./request-id");
|
|
49
78
|
Object.defineProperty(exports, "requestIdMiddleware", { enumerable: true, get: function () { return request_id_1.requestIdMiddleware; } });
|
|
@@ -65,6 +94,7 @@ Object.defineProperty(exports, "identityProvidersLoader", { enumerable: true, ge
|
|
|
65
94
|
Object.defineProperty(exports, "inferenceLogLoader", { enumerable: true, get: function () { return loaders_1.inferenceLogLoader; } });
|
|
66
95
|
Object.defineProperty(exports, "llmLoader", { enumerable: true, get: function () { return loaders_1.llmLoader; } });
|
|
67
96
|
Object.defineProperty(exports, "pubkeyLoader", { enumerable: true, get: function () { return loaders_1.pubkeyLoader; } });
|
|
97
|
+
Object.defineProperty(exports, "requestProtectionLoader", { enumerable: true, get: function () { return loaders_1.requestProtectionLoader; } });
|
|
68
98
|
Object.defineProperty(exports, "requireDatabaseId", { enumerable: true, get: function () { return loaders_1.requireDatabaseId; } });
|
|
69
99
|
Object.defineProperty(exports, "requireIdentityProvider", { enumerable: true, get: function () { return loaders_1.requireIdentityProvider; } });
|
|
70
100
|
Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return loaders_1.rlsLoader; } });
|
package/loaders/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - rlsModule (routing-plane rls_settings)
|
|
10
10
|
* - corsOrigins (routing-plane cors_settings)
|
|
11
11
|
* - databaseSettings(routing-plane database_settings)
|
|
12
|
+
* - requestProtection (routing-plane database_settings/api_settings bounds)
|
|
12
13
|
* - pubkeyChallengeSettings (routing-plane pubkey_settings)
|
|
13
14
|
* - webauthnSettings(routing-plane webauthn_settings)
|
|
14
15
|
* - authSettings (metaschema_modules_public.sessions_module → tenant DB)
|
|
@@ -46,6 +47,7 @@ export { identityProvidersLoader, requireIdentityProvider } from './identity-pro
|
|
|
46
47
|
export { inferenceLogLoader } from './inference-log';
|
|
47
48
|
export { llmLoader } from './llm';
|
|
48
49
|
export { pubkeyLoader } from './pubkey';
|
|
50
|
+
export { requestProtectionLoader } from './request-protection';
|
|
49
51
|
export { rlsLoader } from './rls';
|
|
50
52
|
export { webauthnLoader } from './webauthn';
|
|
51
53
|
export declare function createDefaultRegistry(): import("./registry").LoaderRegistry;
|
package/loaders/index.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - rlsModule (routing-plane rls_settings)
|
|
11
11
|
* - corsOrigins (routing-plane cors_settings)
|
|
12
12
|
* - databaseSettings(routing-plane database_settings)
|
|
13
|
+
* - requestProtection (routing-plane database_settings/api_settings bounds)
|
|
13
14
|
* - pubkeyChallengeSettings (routing-plane pubkey_settings)
|
|
14
15
|
* - webauthnSettings(routing-plane webauthn_settings)
|
|
15
16
|
* - authSettings (metaschema_modules_public.sessions_module → tenant DB)
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
* registry.register(myLoader);
|
|
32
33
|
*/
|
|
33
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
-
exports.webauthnLoader = exports.rlsLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.requireIdentityProvider = exports.identityProvidersLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createLoaderRegistry = exports.createModuleLoader = exports.requireDatabaseId = void 0;
|
|
35
|
+
exports.webauthnLoader = exports.rlsLoader = exports.requestProtectionLoader = exports.pubkeyLoader = exports.llmLoader = exports.inferenceLogLoader = exports.requireIdentityProvider = exports.identityProvidersLoader = exports.databaseSettingsLoader = exports.corsLoader = exports.computeLoader = exports.billingLoader = exports.authSurfaceLoader = exports.authSettingsLoader = exports.agentChatLoader = exports.createLoaderRegistry = exports.createModuleLoader = exports.requireDatabaseId = void 0;
|
|
35
36
|
exports.createDefaultRegistry = createDefaultRegistry;
|
|
36
37
|
var types_1 = require("./types");
|
|
37
38
|
Object.defineProperty(exports, "requireDatabaseId", { enumerable: true, get: function () { return types_1.requireDatabaseId; } });
|
|
@@ -63,6 +64,8 @@ var llm_1 = require("./llm");
|
|
|
63
64
|
Object.defineProperty(exports, "llmLoader", { enumerable: true, get: function () { return llm_1.llmLoader; } });
|
|
64
65
|
var pubkey_1 = require("./pubkey");
|
|
65
66
|
Object.defineProperty(exports, "pubkeyLoader", { enumerable: true, get: function () { return pubkey_1.pubkeyLoader; } });
|
|
67
|
+
var request_protection_1 = require("./request-protection");
|
|
68
|
+
Object.defineProperty(exports, "requestProtectionLoader", { enumerable: true, get: function () { return request_protection_1.requestProtectionLoader; } });
|
|
66
69
|
var rls_1 = require("./rls");
|
|
67
70
|
Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return rls_1.rlsLoader; } });
|
|
68
71
|
var webauthn_1 = require("./webauthn");
|
|
@@ -81,6 +84,7 @@ const inference_log_2 = require("./inference-log");
|
|
|
81
84
|
const llm_2 = require("./llm");
|
|
82
85
|
const pubkey_2 = require("./pubkey");
|
|
83
86
|
const registry_2 = require("./registry");
|
|
87
|
+
const request_protection_2 = require("./request-protection");
|
|
84
88
|
const rls_2 = require("./rls");
|
|
85
89
|
const webauthn_2 = require("./webauthn");
|
|
86
90
|
function createDefaultRegistry() {
|
|
@@ -97,5 +101,6 @@ function createDefaultRegistry() {
|
|
|
97
101
|
registry.register(agent_chat_2.agentChatLoader);
|
|
98
102
|
registry.register(llm_2.llmLoader);
|
|
99
103
|
registry.register(compute_2.computeLoader);
|
|
104
|
+
registry.register(request_protection_2.requestProtectionLoader);
|
|
100
105
|
return registry;
|
|
101
106
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Protection Loader
|
|
3
|
+
*
|
|
4
|
+
* Reads the per-database bounds and the per-API overrides raw, then resolves
|
|
5
|
+
* them through the platform's own defaults and ceilings.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the feature-flag loader next door this cannot `COALESCE` in SQL: an
|
|
8
|
+
* API override is lower-only, so the two scopes are combined with `LEAST` and
|
|
9
|
+
* then clamped in code, where the platform constants live.
|
|
10
|
+
*
|
|
11
|
+
* The columns are also selected by feature detection rather than by name
|
|
12
|
+
* alone. A serving cluster runs routing planes of several ages — the plane is
|
|
13
|
+
* a published package a tenant upgrades on its own schedule — and a plane that
|
|
14
|
+
* predates these columns must resolve to the platform defaults, not fail every
|
|
15
|
+
* request against it with `column ds.… does not exist`.
|
|
16
|
+
*/
|
|
17
|
+
import type { Pool } from 'pg';
|
|
18
|
+
import type { RequestProtection } from '../request-protection';
|
|
19
|
+
import type { ModuleLoader } from './types';
|
|
20
|
+
/** Test seam: forget what a pool's routing plane looked like. */
|
|
21
|
+
export declare const forgetProtectionColumns: (pool: Pool) => void;
|
|
22
|
+
export declare const requestProtectionLoader: ModuleLoader<RequestProtection>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Request Protection Loader
|
|
4
|
+
*
|
|
5
|
+
* Reads the per-database bounds and the per-API overrides raw, then resolves
|
|
6
|
+
* them through the platform's own defaults and ceilings.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the feature-flag loader next door this cannot `COALESCE` in SQL: an
|
|
9
|
+
* API override is lower-only, so the two scopes are combined with `LEAST` and
|
|
10
|
+
* then clamped in code, where the platform constants live.
|
|
11
|
+
*
|
|
12
|
+
* The columns are also selected by feature detection rather than by name
|
|
13
|
+
* alone. A serving cluster runs routing planes of several ages — the plane is
|
|
14
|
+
* a published package a tenant upgrades on its own schedule — and a plane that
|
|
15
|
+
* predates these columns must resolve to the platform defaults, not fail every
|
|
16
|
+
* request against it with `column ds.… does not exist`.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.requestProtectionLoader = exports.forgetProtectionColumns = void 0;
|
|
20
|
+
const request_protection_1 = require("../request-protection");
|
|
21
|
+
const create_loader_1 = require("./create-loader");
|
|
22
|
+
const types_1 = require("./types");
|
|
23
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
24
|
+
const PROTECTION_COLUMNS = [
|
|
25
|
+
'statement_timeout_ms',
|
|
26
|
+
'idle_in_transaction_timeout_ms',
|
|
27
|
+
'lock_timeout_ms',
|
|
28
|
+
'max_concurrent_requests',
|
|
29
|
+
'max_queue_wait_ms',
|
|
30
|
+
'rate_limit_rpm',
|
|
31
|
+
'rate_limit_burst',
|
|
32
|
+
'max_query_depth',
|
|
33
|
+
'max_query_cost',
|
|
34
|
+
'max_page_size',
|
|
35
|
+
'max_request_bytes',
|
|
36
|
+
'enable_introspection'
|
|
37
|
+
];
|
|
38
|
+
const availableColumnsSql = `
|
|
39
|
+
SELECT table_name, column_name
|
|
40
|
+
FROM information_schema.columns
|
|
41
|
+
WHERE table_schema = $1
|
|
42
|
+
AND table_name IN ('database_settings', 'api_settings')
|
|
43
|
+
AND column_name = ANY($2::text[])
|
|
44
|
+
`;
|
|
45
|
+
const requestProtectionSql = (schema, columns) => {
|
|
46
|
+
const select = PROTECTION_COLUMNS.flatMap((column) => [
|
|
47
|
+
columns.database.has(column) ? `ds.${column} AS db_${column}` : null,
|
|
48
|
+
columns.api.has(column) ? `aps.${column} AS api_${column}` : null
|
|
49
|
+
])
|
|
50
|
+
.filter((expr) => expr !== null)
|
|
51
|
+
.join(',\n ');
|
|
52
|
+
// The join is dropped when the plane carries no API-scope column, so a plane
|
|
53
|
+
// without an `api_settings` table at all is still readable.
|
|
54
|
+
const join = columns.api.size
|
|
55
|
+
? `\n LEFT JOIN "${schema}".api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2`
|
|
56
|
+
: '';
|
|
57
|
+
return `
|
|
58
|
+
SELECT
|
|
59
|
+
ds.database_id${select ? `,\n ${select}` : ''}
|
|
60
|
+
FROM "${schema}".database_settings ds${join}
|
|
61
|
+
WHERE ds.database_id = $1
|
|
62
|
+
LIMIT 1
|
|
63
|
+
`;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The routing plane's shape changes only when it is redeployed, so it is read
|
|
67
|
+
* once per pool+schema. Keyed by the pool object so a test pool and a serving
|
|
68
|
+
* pool never share an answer.
|
|
69
|
+
*/
|
|
70
|
+
const columnCache = new WeakMap();
|
|
71
|
+
const availableColumns = (pool, schema) => {
|
|
72
|
+
let bySchema = columnCache.get(pool);
|
|
73
|
+
if (!bySchema) {
|
|
74
|
+
bySchema = new Map();
|
|
75
|
+
columnCache.set(pool, bySchema);
|
|
76
|
+
}
|
|
77
|
+
const cached = bySchema.get(schema);
|
|
78
|
+
if (cached)
|
|
79
|
+
return cached;
|
|
80
|
+
const pending = pool
|
|
81
|
+
.query(availableColumnsSql, [
|
|
82
|
+
schema,
|
|
83
|
+
[...PROTECTION_COLUMNS]
|
|
84
|
+
])
|
|
85
|
+
.then((result) => {
|
|
86
|
+
const columns = { database: new Set(), api: new Set() };
|
|
87
|
+
for (const { table_name, column_name } of result.rows) {
|
|
88
|
+
if (table_name === 'database_settings')
|
|
89
|
+
columns.database.add(column_name);
|
|
90
|
+
else
|
|
91
|
+
columns.api.add(column_name);
|
|
92
|
+
}
|
|
93
|
+
return columns;
|
|
94
|
+
})
|
|
95
|
+
.catch((e) => {
|
|
96
|
+
// A failed probe is not an answer: drop it so the next request retries
|
|
97
|
+
// rather than caching "this plane has no protection columns" forever.
|
|
98
|
+
bySchema.delete(schema);
|
|
99
|
+
throw e;
|
|
100
|
+
});
|
|
101
|
+
bySchema.set(schema, pending);
|
|
102
|
+
return pending;
|
|
103
|
+
};
|
|
104
|
+
/** Test seam: forget what a pool's routing plane looked like. */
|
|
105
|
+
const forgetProtectionColumns = (pool) => {
|
|
106
|
+
columnCache.delete(pool);
|
|
107
|
+
};
|
|
108
|
+
exports.forgetProtectionColumns = forgetProtectionColumns;
|
|
109
|
+
/**
|
|
110
|
+
* `bigint` columns arrive as strings from node-postgres (pg returns int8 as
|
|
111
|
+
* text to avoid the 2^53 cliff), so every numeric bound is normalized here
|
|
112
|
+
* rather than trusted to already be a number.
|
|
113
|
+
*/
|
|
114
|
+
const num = (value) => {
|
|
115
|
+
if (value === null || value === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
118
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
119
|
+
};
|
|
120
|
+
const bool = (value) => value === null || value === undefined ? null : Boolean(value);
|
|
121
|
+
const scopeOf = (row, prefix) => ({
|
|
122
|
+
statementTimeoutMs: num(row[`${prefix}_statement_timeout_ms`]),
|
|
123
|
+
idleInTransactionTimeoutMs: num(row[`${prefix}_idle_in_transaction_timeout_ms`]),
|
|
124
|
+
lockTimeoutMs: num(row[`${prefix}_lock_timeout_ms`]),
|
|
125
|
+
maxConcurrentRequests: num(row[`${prefix}_max_concurrent_requests`]),
|
|
126
|
+
maxQueueWaitMs: num(row[`${prefix}_max_queue_wait_ms`]),
|
|
127
|
+
rateLimitRpm: num(row[`${prefix}_rate_limit_rpm`]),
|
|
128
|
+
rateLimitBurst: num(row[`${prefix}_rate_limit_burst`]),
|
|
129
|
+
maxQueryDepth: num(row[`${prefix}_max_query_depth`]),
|
|
130
|
+
maxQueryCost: num(row[`${prefix}_max_query_cost`]),
|
|
131
|
+
maxPageSize: num(row[`${prefix}_max_page_size`]),
|
|
132
|
+
maxRequestBytes: num(row[`${prefix}_max_request_bytes`]),
|
|
133
|
+
enableIntrospection: bool(row[`${prefix}_enable_introspection`])
|
|
134
|
+
});
|
|
135
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
136
|
+
exports.requestProtectionLoader = (0, create_loader_1.createModuleLoader)({
|
|
137
|
+
name: 'requestProtection',
|
|
138
|
+
ttlMs: 5 * 60_000,
|
|
139
|
+
async resolve(ctx) {
|
|
140
|
+
const { routingPool, databaseId, apiId } = ctx;
|
|
141
|
+
const schema = (0, types_1.routingSchemaOf)(ctx);
|
|
142
|
+
const columns = await availableColumns(routingPool, schema);
|
|
143
|
+
const params = columns.api.size ? [databaseId, apiId ?? null] : [databaseId];
|
|
144
|
+
const result = await routingPool.query(requestProtectionSql(schema, columns), params);
|
|
145
|
+
const row = result.rows[0];
|
|
146
|
+
if (!row)
|
|
147
|
+
return undefined;
|
|
148
|
+
return (0, request_protection_1.resolveRequestProtection)(scopeOf(row, 'db'), scopeOf(row, 'api'));
|
|
149
|
+
}
|
|
150
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/express-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
|
@@ -29,21 +29,21 @@
|
|
|
29
29
|
"test:watch": "jest --watch"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@constructive-io/url-domains": "^2.30.
|
|
33
|
-
"@pgpmjs/env": "^2.43.
|
|
34
|
-
"@pgpmjs/logger": "^2.25.
|
|
35
|
-
"@pgpmjs/server-utils": "^3.27.
|
|
36
|
-
"@pgpmjs/types": "^2.
|
|
32
|
+
"@constructive-io/url-domains": "^2.30.1",
|
|
33
|
+
"@pgpmjs/env": "^2.43.3",
|
|
34
|
+
"@pgpmjs/logger": "^2.25.1",
|
|
35
|
+
"@pgpmjs/server-utils": "^3.27.3",
|
|
36
|
+
"@pgpmjs/types": "^2.53.1",
|
|
37
37
|
"lru-cache": "^11.2.7",
|
|
38
38
|
"pg": "^8.21.0",
|
|
39
|
-
"pg-cache": "^3.27.
|
|
40
|
-
"pg-query-context": "^2.30.
|
|
39
|
+
"pg-cache": "^3.27.3",
|
|
40
|
+
"pg-query-context": "^2.30.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/express": "^5.0.6",
|
|
44
44
|
"@types/pg": "^8.20.4",
|
|
45
45
|
"express": "^5.2.1",
|
|
46
|
-
"makage": "^0.
|
|
46
|
+
"makage": "^0.8.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"express": "^5.0.0"
|
|
@@ -59,5 +59,5 @@
|
|
|
59
59
|
"jwt",
|
|
60
60
|
"pgSettings"
|
|
61
61
|
],
|
|
62
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "6e2d71a9eb4e1f49f0de0b2b594b2f811de97442"
|
|
63
63
|
}
|
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;
|