@constructive-io/express-context 0.26.0 → 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 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';
@@ -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; } });
@@ -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.0",
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.0",
33
- "@pgpmjs/env": "^2.43.0",
34
- "@pgpmjs/logger": "^2.25.0",
35
- "@pgpmjs/server-utils": "^3.27.0",
36
- "@pgpmjs/types": "^2.52.0",
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.0",
40
- "pg-query-context": "^2.30.0"
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.3.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": "b5b6b9f05276b7e9cbd3d1f4acd31dd315c353ea"
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