@constructive-io/express-context 0.26.1 → 0.26.2
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/esm/index.js +2 -1
- package/esm/loaders/index.js +4 -0
- package/esm/loaders/request-protection.js +146 -0
- package/esm/request-protection.js +106 -0
- package/index.d.ts +3 -1
- package/index.js +9 -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/request-protection.d.ts +117 -0
- package/request-protection.js +111 -0
- package/types.d.ts +2 -0
package/esm/index.js
CHANGED
|
@@ -38,9 +38,10 @@ export { createBillingClient } from './billing-client';
|
|
|
38
38
|
export { buildPgSettings } from './pg-settings';
|
|
39
39
|
// withPgClient helper
|
|
40
40
|
export { withPgClient } from './pg-client';
|
|
41
|
+
export { ASSUMED_PAGE_SIZE, DEFAULT_REQUEST_PROTECTION, INTROSPECTION_BOUND, PROTECTION_BOUNDS, protectionPgSettings, resolveRequestProtection, } from './request-protection';
|
|
41
42
|
// Request ID middleware
|
|
42
43
|
export { requestIdMiddleware } from './request-id';
|
|
43
44
|
export { buildContext, createContextMiddleware } from './context';
|
|
44
|
-
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
45
|
+
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
45
46
|
// Side-effect: Express type augmentation
|
|
46
47
|
import './types';
|
package/esm/loaders/index.js
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)
|
|
@@ -44,6 +45,7 @@ export { identityProvidersLoader, requireIdentityProvider } from './identity-pro
|
|
|
44
45
|
export { inferenceLogLoader } from './inference-log';
|
|
45
46
|
export { llmLoader } from './llm';
|
|
46
47
|
export { pubkeyLoader } from './pubkey';
|
|
48
|
+
export { requestProtectionLoader } from './request-protection';
|
|
47
49
|
export { rlsLoader } from './rls';
|
|
48
50
|
export { webauthnLoader } from './webauthn';
|
|
49
51
|
/**
|
|
@@ -60,6 +62,7 @@ import { inferenceLogLoader } from './inference-log';
|
|
|
60
62
|
import { llmLoader } from './llm';
|
|
61
63
|
import { pubkeyLoader } from './pubkey';
|
|
62
64
|
import { createLoaderRegistry } from './registry';
|
|
65
|
+
import { requestProtectionLoader } from './request-protection';
|
|
63
66
|
import { rlsLoader } from './rls';
|
|
64
67
|
import { webauthnLoader } from './webauthn';
|
|
65
68
|
export function createDefaultRegistry() {
|
|
@@ -76,5 +79,6 @@ export function createDefaultRegistry() {
|
|
|
76
79
|
registry.register(agentChatLoader);
|
|
77
80
|
registry.register(llmLoader);
|
|
78
81
|
registry.register(computeLoader);
|
|
82
|
+
registry.register(requestProtectionLoader);
|
|
79
83
|
return registry;
|
|
80
84
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 { resolveRequestProtection } from '../request-protection';
|
|
18
|
+
import { createModuleLoader } from './create-loader';
|
|
19
|
+
import { routingSchemaOf } from './types';
|
|
20
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
21
|
+
const PROTECTION_COLUMNS = [
|
|
22
|
+
'statement_timeout_ms',
|
|
23
|
+
'idle_in_transaction_timeout_ms',
|
|
24
|
+
'lock_timeout_ms',
|
|
25
|
+
'max_concurrent_requests',
|
|
26
|
+
'max_queue_wait_ms',
|
|
27
|
+
'rate_limit_rpm',
|
|
28
|
+
'rate_limit_burst',
|
|
29
|
+
'max_query_depth',
|
|
30
|
+
'max_query_cost',
|
|
31
|
+
'max_page_size',
|
|
32
|
+
'max_request_bytes',
|
|
33
|
+
'enable_introspection'
|
|
34
|
+
];
|
|
35
|
+
const availableColumnsSql = `
|
|
36
|
+
SELECT table_name, column_name
|
|
37
|
+
FROM information_schema.columns
|
|
38
|
+
WHERE table_schema = $1
|
|
39
|
+
AND table_name IN ('database_settings', 'api_settings')
|
|
40
|
+
AND column_name = ANY($2::text[])
|
|
41
|
+
`;
|
|
42
|
+
const requestProtectionSql = (schema, columns) => {
|
|
43
|
+
const select = PROTECTION_COLUMNS.flatMap((column) => [
|
|
44
|
+
columns.database.has(column) ? `ds.${column} AS db_${column}` : null,
|
|
45
|
+
columns.api.has(column) ? `aps.${column} AS api_${column}` : null
|
|
46
|
+
])
|
|
47
|
+
.filter((expr) => expr !== null)
|
|
48
|
+
.join(',\n ');
|
|
49
|
+
// The join is dropped when the plane carries no API-scope column, so a plane
|
|
50
|
+
// without an `api_settings` table at all is still readable.
|
|
51
|
+
const join = columns.api.size
|
|
52
|
+
? `\n LEFT JOIN "${schema}".api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2`
|
|
53
|
+
: '';
|
|
54
|
+
return `
|
|
55
|
+
SELECT
|
|
56
|
+
ds.database_id${select ? `,\n ${select}` : ''}
|
|
57
|
+
FROM "${schema}".database_settings ds${join}
|
|
58
|
+
WHERE ds.database_id = $1
|
|
59
|
+
LIMIT 1
|
|
60
|
+
`;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The routing plane's shape changes only when it is redeployed, so it is read
|
|
64
|
+
* once per pool+schema. Keyed by the pool object so a test pool and a serving
|
|
65
|
+
* pool never share an answer.
|
|
66
|
+
*/
|
|
67
|
+
const columnCache = new WeakMap();
|
|
68
|
+
const availableColumns = (pool, schema) => {
|
|
69
|
+
let bySchema = columnCache.get(pool);
|
|
70
|
+
if (!bySchema) {
|
|
71
|
+
bySchema = new Map();
|
|
72
|
+
columnCache.set(pool, bySchema);
|
|
73
|
+
}
|
|
74
|
+
const cached = bySchema.get(schema);
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached;
|
|
77
|
+
const pending = pool
|
|
78
|
+
.query(availableColumnsSql, [
|
|
79
|
+
schema,
|
|
80
|
+
[...PROTECTION_COLUMNS]
|
|
81
|
+
])
|
|
82
|
+
.then((result) => {
|
|
83
|
+
const columns = { database: new Set(), api: new Set() };
|
|
84
|
+
for (const { table_name, column_name } of result.rows) {
|
|
85
|
+
if (table_name === 'database_settings')
|
|
86
|
+
columns.database.add(column_name);
|
|
87
|
+
else
|
|
88
|
+
columns.api.add(column_name);
|
|
89
|
+
}
|
|
90
|
+
return columns;
|
|
91
|
+
})
|
|
92
|
+
.catch((e) => {
|
|
93
|
+
// A failed probe is not an answer: drop it so the next request retries
|
|
94
|
+
// rather than caching "this plane has no protection columns" forever.
|
|
95
|
+
bySchema.delete(schema);
|
|
96
|
+
throw e;
|
|
97
|
+
});
|
|
98
|
+
bySchema.set(schema, pending);
|
|
99
|
+
return pending;
|
|
100
|
+
};
|
|
101
|
+
/** Test seam: forget what a pool's routing plane looked like. */
|
|
102
|
+
export const forgetProtectionColumns = (pool) => {
|
|
103
|
+
columnCache.delete(pool);
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* `bigint` columns arrive as strings from node-postgres (pg returns int8 as
|
|
107
|
+
* text to avoid the 2^53 cliff), so every numeric bound is normalized here
|
|
108
|
+
* rather than trusted to already be a number.
|
|
109
|
+
*/
|
|
110
|
+
const num = (value) => {
|
|
111
|
+
if (value === null || value === undefined)
|
|
112
|
+
return null;
|
|
113
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
114
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
115
|
+
};
|
|
116
|
+
const bool = (value) => value === null || value === undefined ? null : Boolean(value);
|
|
117
|
+
const scopeOf = (row, prefix) => ({
|
|
118
|
+
statementTimeoutMs: num(row[`${prefix}_statement_timeout_ms`]),
|
|
119
|
+
idleInTransactionTimeoutMs: num(row[`${prefix}_idle_in_transaction_timeout_ms`]),
|
|
120
|
+
lockTimeoutMs: num(row[`${prefix}_lock_timeout_ms`]),
|
|
121
|
+
maxConcurrentRequests: num(row[`${prefix}_max_concurrent_requests`]),
|
|
122
|
+
maxQueueWaitMs: num(row[`${prefix}_max_queue_wait_ms`]),
|
|
123
|
+
rateLimitRpm: num(row[`${prefix}_rate_limit_rpm`]),
|
|
124
|
+
rateLimitBurst: num(row[`${prefix}_rate_limit_burst`]),
|
|
125
|
+
maxQueryDepth: num(row[`${prefix}_max_query_depth`]),
|
|
126
|
+
maxQueryCost: num(row[`${prefix}_max_query_cost`]),
|
|
127
|
+
maxPageSize: num(row[`${prefix}_max_page_size`]),
|
|
128
|
+
maxRequestBytes: num(row[`${prefix}_max_request_bytes`]),
|
|
129
|
+
enableIntrospection: bool(row[`${prefix}_enable_introspection`])
|
|
130
|
+
});
|
|
131
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
132
|
+
export const requestProtectionLoader = createModuleLoader({
|
|
133
|
+
name: 'requestProtection',
|
|
134
|
+
ttlMs: 5 * 60_000,
|
|
135
|
+
async resolve(ctx) {
|
|
136
|
+
const { routingPool, databaseId, apiId } = ctx;
|
|
137
|
+
const schema = routingSchemaOf(ctx);
|
|
138
|
+
const columns = await availableColumns(routingPool, schema);
|
|
139
|
+
const params = columns.api.size ? [databaseId, apiId ?? null] : [databaseId];
|
|
140
|
+
const result = await routingPool.query(requestProtectionSql(schema, columns), params);
|
|
141
|
+
const row = result.rows[0];
|
|
142
|
+
if (!row)
|
|
143
|
+
return undefined;
|
|
144
|
+
return resolveRequestProtection(scopeOf(row, 'db'), scopeOf(row, 'api'));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
// ─── Platform bounds ────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* The platform's own numbers. Deliberately constants: a ceiling stored next to
|
|
21
|
+
* the value it caps is a ceiling a tenant can edit.
|
|
22
|
+
*/
|
|
23
|
+
export const PROTECTION_BOUNDS = {
|
|
24
|
+
statementTimeoutMs: { default: 30_000, floor: 1_000, max: 120_000 },
|
|
25
|
+
idleInTransactionTimeoutMs: { default: 60_000, floor: 1_000, max: 300_000 },
|
|
26
|
+
lockTimeoutMs: { default: 5_000, floor: 100, max: 60_000 },
|
|
27
|
+
maxConcurrentRequests: { default: 50, floor: 1, max: 500 },
|
|
28
|
+
maxQueueWaitMs: { default: 5_000, floor: 0, max: 60_000 },
|
|
29
|
+
rateLimitRpm: { default: 600, floor: 1, max: 60_000 },
|
|
30
|
+
rateLimitBurst: { default: 60, floor: 1, max: 10_000 },
|
|
31
|
+
maxQueryDepth: { default: 12, floor: 1, max: 50 },
|
|
32
|
+
// Cost is measured in rows a document can pull (see ASSUMED_PAGE_SIZE), so
|
|
33
|
+
// the default has to leave room for the nesting Graphile encourages while
|
|
34
|
+
// still refusing the millions-of-rows shapes.
|
|
35
|
+
maxQueryCost: { default: 1_000_000, floor: 100, max: 100_000_000 },
|
|
36
|
+
maxPageSize: { default: 1_000, floor: 1, max: 10_000 },
|
|
37
|
+
maxRequestBytes: { default: 1_000_000, floor: 1_024, max: 10_000_000 }
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What a connection with no `first`/`last` is charged.
|
|
41
|
+
*
|
|
42
|
+
* Such a field is unbounded in principle, so charging it the tenant's
|
|
43
|
+
* `maxPageSize` would make a large ceiling self-defeating: raising the page
|
|
44
|
+
* size a client *may* ask for would shrink the query it can write. A fixed
|
|
45
|
+
* estimate keeps the two bounds independent.
|
|
46
|
+
*/
|
|
47
|
+
export const ASSUMED_PAGE_SIZE = 100;
|
|
48
|
+
/**
|
|
49
|
+
* Introspection stays on until metadata says otherwise, and `platformAllows`
|
|
50
|
+
* is the kill switch that takes it away cluster-wide regardless.
|
|
51
|
+
*
|
|
52
|
+
* The platform default is permissive here where the metadata default is not
|
|
53
|
+
* (`database_settings.enable_introspection` is `NOT NULL DEFAULT false`) on
|
|
54
|
+
* purpose: this value applies to a database that has expressed *no*
|
|
55
|
+
* preference — typically one whose routing plane predates the column — and
|
|
56
|
+
* silently disabling introspection for it would break GraphiQL and codegen for
|
|
57
|
+
* every tenant the moment this code deployed, without anyone opting in.
|
|
58
|
+
*/
|
|
59
|
+
export const INTROSPECTION_BOUND = {
|
|
60
|
+
default: true,
|
|
61
|
+
platformAllows: true
|
|
62
|
+
};
|
|
63
|
+
/** What a database with no settings row gets. */
|
|
64
|
+
export const DEFAULT_REQUEST_PROTECTION = resolveDefaults();
|
|
65
|
+
function resolveDefaults() {
|
|
66
|
+
const numeric = Object.fromEntries(Object.entries(PROTECTION_BOUNDS).map(([key, bound]) => [key, bound.default]));
|
|
67
|
+
return { ...numeric, enableIntrospection: INTROSPECTION_BOUND.default };
|
|
68
|
+
}
|
|
69
|
+
// ─── Resolution ─────────────────────────────────────────────────────────────
|
|
70
|
+
const clamp = (value, bound) => Math.min(Math.max(value, bound.floor), bound.max);
|
|
71
|
+
/** The stricter of two preferences, ignoring the scopes that expressed none. */
|
|
72
|
+
const strictest = (a, b) => {
|
|
73
|
+
const values = [a, b].filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
74
|
+
return values.length > 0 ? Math.min(...values) : null;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the bounds a request runs under.
|
|
78
|
+
*
|
|
79
|
+
* @param database - the `database_settings` row for the tenant, if any
|
|
80
|
+
* @param api - the `api_settings` overrides for the API the request arrived on
|
|
81
|
+
*/
|
|
82
|
+
export function resolveRequestProtection(database, api) {
|
|
83
|
+
const resolved = {};
|
|
84
|
+
for (const [name, bound] of Object.entries(PROTECTION_BOUNDS)) {
|
|
85
|
+
const preferred = strictest(database?.[name], api?.[name]);
|
|
86
|
+
resolved[name] = clamp(preferred ?? bound.default, bound);
|
|
87
|
+
}
|
|
88
|
+
// Lower-only applies to the flag too: an API may switch introspection off
|
|
89
|
+
// for itself, never back on for a database that turned it off, and the
|
|
90
|
+
// platform switch can only ever take it away.
|
|
91
|
+
const enableIntrospection = (database?.enableIntrospection ?? INTROSPECTION_BOUND.default) &&
|
|
92
|
+
(api?.enableIntrospection ?? true) &&
|
|
93
|
+
INTROSPECTION_BOUND.platformAllows;
|
|
94
|
+
return { ...resolved, enableIntrospection };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The timeout GUCs as `SET LOCAL` pairs, for merging into a request's
|
|
98
|
+
* pgSettings. PostgreSQL reads a bare integer for all three as milliseconds.
|
|
99
|
+
*/
|
|
100
|
+
export function protectionPgSettings(protection) {
|
|
101
|
+
return {
|
|
102
|
+
statement_timeout: String(protection.statementTimeoutMs),
|
|
103
|
+
idle_in_transaction_session_timeout: String(protection.idleInTransactionTimeoutMs),
|
|
104
|
+
lock_timeout: String(protection.lockTimeoutMs)
|
|
105
|
+
};
|
|
106
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -40,9 +40,11 @@ export { createBillingClient } from './billing-client';
|
|
|
40
40
|
export type { PgSettingsInput } from './pg-settings';
|
|
41
41
|
export { buildPgSettings } from './pg-settings';
|
|
42
42
|
export { withPgClient } from './pg-client';
|
|
43
|
+
export type { NumericProtectionKey, ProtectionBound, RequestProtection, RequestProtectionInput, } from './request-protection';
|
|
44
|
+
export { ASSUMED_PAGE_SIZE, DEFAULT_REQUEST_PROTECTION, INTROSPECTION_BOUND, PROTECTION_BOUNDS, protectionPgSettings, resolveRequestProtection, } from './request-protection';
|
|
43
45
|
export { requestIdMiddleware } from './request-id';
|
|
44
46
|
export type { ContextMiddlewareOptions } from './context';
|
|
45
47
|
export { buildContext, createContextMiddleware } from './context';
|
|
46
48
|
export type { CreateLoaderOptions, LoaderContext, LoaderRegistry, ModuleLoader, } from './loaders';
|
|
47
|
-
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
49
|
+
export { agentChatLoader, authSettingsLoader, authSurfaceLoader, billingLoader, computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, requestProtectionLoader, requireDatabaseId, requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders';
|
|
48
50
|
import './types';
|
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.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,13 @@ 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; } });
|
|
47
54
|
// Request ID middleware
|
|
48
55
|
var request_id_1 = require("./request-id");
|
|
49
56
|
Object.defineProperty(exports, "requestIdMiddleware", { enumerable: true, get: function () { return request_id_1.requestIdMiddleware; } });
|
|
@@ -65,6 +72,7 @@ Object.defineProperty(exports, "identityProvidersLoader", { enumerable: true, ge
|
|
|
65
72
|
Object.defineProperty(exports, "inferenceLogLoader", { enumerable: true, get: function () { return loaders_1.inferenceLogLoader; } });
|
|
66
73
|
Object.defineProperty(exports, "llmLoader", { enumerable: true, get: function () { return loaders_1.llmLoader; } });
|
|
67
74
|
Object.defineProperty(exports, "pubkeyLoader", { enumerable: true, get: function () { return loaders_1.pubkeyLoader; } });
|
|
75
|
+
Object.defineProperty(exports, "requestProtectionLoader", { enumerable: true, get: function () { return loaders_1.requestProtectionLoader; } });
|
|
68
76
|
Object.defineProperty(exports, "requireDatabaseId", { enumerable: true, get: function () { return loaders_1.requireDatabaseId; } });
|
|
69
77
|
Object.defineProperty(exports, "requireIdentityProvider", { enumerable: true, get: function () { return loaders_1.requireIdentityProvider; } });
|
|
70
78
|
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.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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.2",
|
|
34
|
+
"@pgpmjs/logger": "^2.25.1",
|
|
35
|
+
"@pgpmjs/server-utils": "^3.27.2",
|
|
36
|
+
"@pgpmjs/types": "^2.53.0",
|
|
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.2",
|
|
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": "5e0bfead2856d1a062b495d343a43e543343f9b8"
|
|
63
63
|
}
|
|
@@ -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 allowed per tenant (not yet enforced). */
|
|
27
|
+
maxConcurrentRequests: number;
|
|
28
|
+
/** How long a request may wait for a concurrency slot (not yet enforced). */
|
|
29
|
+
maxQueueWaitMs: number;
|
|
30
|
+
/** Sustained request rate per tenant (not yet enforced). */
|
|
31
|
+
rateLimitRpm: number;
|
|
32
|
+
/** Burst allowance above `rateLimitRpm` (not yet enforced). */
|
|
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
|