@constructive-io/graphql-server 5.29.2 → 5.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/control-role.d.ts +8 -0
- package/control-role.js +15 -0
- package/diagnostics/debug-db-snapshot.js +2 -0
- package/esm/control-role.js +11 -0
- package/esm/diagnostics/debug-db-snapshot.js +2 -0
- package/esm/middleware/api.js +4 -2
- package/esm/middleware/auth.js +1 -0
- package/esm/middleware/flush.js +3 -1
- package/esm/middleware/graphile.js +7 -3
- package/esm/refusals/recorder.js +5 -2
- package/esm/server.js +4 -1
- package/middleware/api.js +4 -2
- package/middleware/auth.js +1 -0
- package/middleware/flush.js +3 -1
- package/middleware/graphile.js +7 -3
- package/middleware/routing.d.ts +2 -2
- package/package.json +11 -11
- package/refusals/recorder.d.ts +2 -2
- package/refusals/recorder.js +5 -2
- package/server.js +4 -1
package/README.md
CHANGED
|
@@ -146,6 +146,7 @@ Configuration is merged from defaults, config files, and env vars via `@construc
|
|
|
146
146
|
| `API_ROLE_NAME` | Authenticated role name | `administrator` |
|
|
147
147
|
| `API_FLUSH_TOKEN` | Bearer token required by `POST /flush`; route is not mounted when unset | empty |
|
|
148
148
|
| `API_INTROSPECTION_ROLE` | Role PostGraphile introspects as; unset means the pool's connecting role. Naming a role with fewer grants removes fields from the schema | empty |
|
|
149
|
+
| `API_CONTROL_ROLE` | Role control-plane queries (routing, API lookups, module loaders, flush, refusal log) run as via `SET LOCAL ROLE`. The login role holds no privileges, so the server refuses to start without it | required |
|
|
149
150
|
| `GRAPHQL_OBSERVABILITY_ENABLED` | Master switch for debug routes and sampler | `false` |
|
|
150
151
|
| `GRAPHQL_DEBUG_SAMPLER_ENABLED` | Enables periodic NDJSON sampling when observability is on | `true` |
|
|
151
152
|
| `GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS` | Sampler interval in milliseconds | `10000` |
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ApiOptions } from '@constructive-io/graphql-types';
|
|
2
|
+
/**
|
|
3
|
+
* The role control-plane queries run as (`api.controlRole` / `API_CONTROL_ROLE`).
|
|
4
|
+
* The login role is NOINHERIT and holds no privileges, so there is no default.
|
|
5
|
+
*/
|
|
6
|
+
export declare const getControlRole: (opts: {
|
|
7
|
+
api?: ApiOptions;
|
|
8
|
+
}) => string;
|
package/control-role.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getControlRole = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The role control-plane queries run as (`api.controlRole` / `API_CONTROL_ROLE`).
|
|
6
|
+
* The login role is NOINHERIT and holds no privileges, so there is no default.
|
|
7
|
+
*/
|
|
8
|
+
const getControlRole = (opts) => {
|
|
9
|
+
const role = opts.api?.controlRole;
|
|
10
|
+
if (!role) {
|
|
11
|
+
throw new Error('GraphQL server: api.controlRole (API_CONTROL_ROLE) is required — control-plane queries run as that role');
|
|
12
|
+
}
|
|
13
|
+
return role;
|
|
14
|
+
};
|
|
15
|
+
exports.getControlRole = getControlRole;
|
|
@@ -4,6 +4,7 @@ exports.getDebugDatabaseSnapshot = exports.closeDebugDatabasePools = void 0;
|
|
|
4
4
|
const pg_1 = require("pg");
|
|
5
5
|
const pg_cache_1 = require("pg-cache");
|
|
6
6
|
const pg_env_1 = require("pg-env");
|
|
7
|
+
const control_role_1 = require("../control-role");
|
|
7
8
|
const ACTIVE_ACTIVITY_SQL = `
|
|
8
9
|
select
|
|
9
10
|
pid,
|
|
@@ -141,6 +142,7 @@ const withDiagnosticsClient = async (opts, fn) => {
|
|
|
141
142
|
const client = await diagnosticsPool.connect();
|
|
142
143
|
try {
|
|
143
144
|
await client.query('BEGIN');
|
|
145
|
+
await client.query('SELECT set_config($1, $2, true)', ['role', (0, control_role_1.getControlRole)(opts)]);
|
|
144
146
|
await client.query(`SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'`);
|
|
145
147
|
await client.query(`SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'`);
|
|
146
148
|
const result = await fn(client);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The role control-plane queries run as (`api.controlRole` / `API_CONTROL_ROLE`).
|
|
3
|
+
* The login role is NOINHERIT and holds no privileges, so there is no default.
|
|
4
|
+
*/
|
|
5
|
+
export const getControlRole = (opts) => {
|
|
6
|
+
const role = opts.api?.controlRole;
|
|
7
|
+
if (!role) {
|
|
8
|
+
throw new Error('GraphQL server: api.controlRole (API_CONTROL_ROLE) is required — control-plane queries run as that role');
|
|
9
|
+
}
|
|
10
|
+
return role;
|
|
11
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
2
|
import { buildConnectionString, getPgPool } from 'pg-cache';
|
|
3
3
|
import { getPgEnvOptions } from 'pg-env';
|
|
4
|
+
import { getControlRole } from '../control-role';
|
|
4
5
|
const ACTIVE_ACTIVITY_SQL = `
|
|
5
6
|
select
|
|
6
7
|
pid,
|
|
@@ -138,6 +139,7 @@ const withDiagnosticsClient = async (opts, fn) => {
|
|
|
138
139
|
const client = await diagnosticsPool.connect();
|
|
139
140
|
try {
|
|
140
141
|
await client.query('BEGIN');
|
|
142
|
+
await client.query('SELECT set_config($1, $2, true)', ['role', getControlRole(opts)]);
|
|
141
143
|
await client.query(`SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'`);
|
|
142
144
|
await client.query(`SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'`);
|
|
143
145
|
const result = await fn(client);
|
package/esm/middleware/api.js
CHANGED
|
@@ -4,6 +4,8 @@ import { parseUrl } from '@constructive-io/url-domains';
|
|
|
4
4
|
import { Logger } from '@pgpmjs/logger';
|
|
5
5
|
import { svcCache } from '@pgpmjs/server-utils';
|
|
6
6
|
import { getPgPool } from 'pg-cache';
|
|
7
|
+
import { controlPlanePool } from 'pg-query-context';
|
|
8
|
+
import { getControlRole } from '../control-role';
|
|
7
9
|
import errorPage50x from '../errors/50x';
|
|
8
10
|
import errorPage404Message from '../errors/404-message';
|
|
9
11
|
import { getRoutingSchema, isValidSchemaName, requireApiRole, resolveRoute, routeToApiStructure } from './routing';
|
|
@@ -48,7 +50,7 @@ const buildLoaderContext = (routingPool, opts, row) => {
|
|
|
48
50
|
return {
|
|
49
51
|
routingPool,
|
|
50
52
|
routingSchema: getRoutingSchema(opts),
|
|
51
|
-
tenantPool: getPgPool({ ...opts.pg, database: dbname }),
|
|
53
|
+
tenantPool: controlPlanePool(getPgPool({ ...opts.pg, database: dbname }), routingPool.role),
|
|
52
54
|
databaseId: row.database_id,
|
|
53
55
|
apiId: row.api_id,
|
|
54
56
|
dbname
|
|
@@ -260,7 +262,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
260
262
|
// Main Resolution Function
|
|
261
263
|
// =============================================================================
|
|
262
264
|
export const getApiConfig = async (opts, req) => {
|
|
263
|
-
const pool = getPgPool(opts.pg);
|
|
265
|
+
const pool = controlPlanePool(getPgPool(opts.pg), getControlRole(opts));
|
|
264
266
|
const { domain, subdomains } = getUrlDomains(req);
|
|
265
267
|
const subdomain = getSubdomain(subdomains);
|
|
266
268
|
const cacheKey = getSvcKey(opts, req);
|
package/esm/middleware/auth.js
CHANGED
|
@@ -62,6 +62,7 @@ export const createAuthenticateMiddleware = (opts) => {
|
|
|
62
62
|
if (effectiveToken) {
|
|
63
63
|
log.info(`[auth] Processing ${tokenSource} authentication`);
|
|
64
64
|
const context = {
|
|
65
|
+
role: api.anonRole,
|
|
65
66
|
'jwt.claims.ip_address': req.clientIp,
|
|
66
67
|
};
|
|
67
68
|
if (req.get('origin')) {
|
package/esm/middleware/flush.js
CHANGED
|
@@ -4,6 +4,8 @@ import { svcCache } from '@pgpmjs/server-utils';
|
|
|
4
4
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
5
5
|
import { graphileCache } from 'graphile-cache';
|
|
6
6
|
import { getPgPool } from 'pg-cache';
|
|
7
|
+
import { controlPlanePool } from 'pg-query-context';
|
|
8
|
+
import { getControlRole } from '../control-role';
|
|
7
9
|
import { getRoutingSchema, isValidSchemaName } from './routing';
|
|
8
10
|
const log = new Logger('flush');
|
|
9
11
|
const bearerToken = (req) => {
|
|
@@ -47,7 +49,7 @@ export const createFlushMiddleware = (opts) => {
|
|
|
47
49
|
};
|
|
48
50
|
};
|
|
49
51
|
export const flushService = async (opts, databaseId) => {
|
|
50
|
-
const pgPool = getPgPool(opts.pg);
|
|
52
|
+
const pgPool = controlPlanePool(getPgPool(opts.pg), getControlRole(opts));
|
|
51
53
|
log.info('flushing db ' + databaseId);
|
|
52
54
|
const api = new RegExp(`^api:${databaseId}:.*`);
|
|
53
55
|
const schemata = new RegExp(`^schemata:${databaseId}:.*`);
|
|
@@ -7,6 +7,8 @@ import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
|
7
7
|
import { createConstructivePreset, createGrafastCacheLimitsPreset } from 'graphile-settings';
|
|
8
8
|
import { getPgPool } from 'pg-cache';
|
|
9
9
|
import { getPgEnvOptions } from 'pg-env';
|
|
10
|
+
import { controlPlanePool } from 'pg-query-context';
|
|
11
|
+
import { getControlRole } from '../control-role';
|
|
10
12
|
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
11
13
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
12
14
|
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
@@ -56,7 +58,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
56
58
|
* plugin preset. Without settings the default preset is used
|
|
57
59
|
* (everything on except aggregates).
|
|
58
60
|
*/
|
|
59
|
-
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
61
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, controlRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
60
62
|
const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
61
63
|
const configuredPreset = graphileOptions?.preset ?? {};
|
|
62
64
|
const grafastCachePreset = createGrafastCacheLimitsPreset(graphileOptions?.grafastCache);
|
|
@@ -87,6 +89,7 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
87
89
|
? [
|
|
88
90
|
createFunctionBindingsPlugin({
|
|
89
91
|
apiId,
|
|
92
|
+
role: controlRole,
|
|
90
93
|
modules: compute.modules.map((m) => ({
|
|
91
94
|
computeSchema: m.schemaName,
|
|
92
95
|
bindingsTable: m.bindingsTableName,
|
|
@@ -311,7 +314,7 @@ export const graphile = (opts) => {
|
|
|
311
314
|
// concurrent requests coalesce while optional plugin modules load.
|
|
312
315
|
const creationPromise = (async () => {
|
|
313
316
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
314
|
-
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
317
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, getControlRole(opts), opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
315
318
|
return observeGraphileBuild({
|
|
316
319
|
cacheKey: key,
|
|
317
320
|
serviceKey: key,
|
|
@@ -319,7 +322,8 @@ export const graphile = (opts) => {
|
|
|
319
322
|
}, () => createGraphileInstance({
|
|
320
323
|
preset,
|
|
321
324
|
cacheKey: key,
|
|
322
|
-
enableRealtime: api.databaseSettings?.enableRealtime
|
|
325
|
+
enableRealtime: api.databaseSettings?.enableRealtime,
|
|
326
|
+
realtimePool: controlPlanePool(pool, getControlRole(opts))
|
|
323
327
|
}), { enabled: observabilityEnabled });
|
|
324
328
|
})();
|
|
325
329
|
creating.set(key, creationPromise);
|
package/esm/refusals/recorder.js
CHANGED
|
@@ -2,6 +2,8 @@ import '../middleware/types'; // for Request type
|
|
|
2
2
|
import { clientIpFrom, createRecordRefusalsSink, RefusalRecorder, trustedProxyHops } from '@constructive-io/express-context';
|
|
3
3
|
import { Logger } from '@pgpmjs/logger';
|
|
4
4
|
import { getPgPool } from 'pg-cache';
|
|
5
|
+
import { controlPlanePool } from 'pg-query-context';
|
|
6
|
+
import { getControlRole } from '../control-role';
|
|
5
7
|
const log = new Logger('refusals');
|
|
6
8
|
/**
|
|
7
9
|
* refusals/recorder — the GraphQL lane's `RefusalRecorder`.
|
|
@@ -119,16 +121,17 @@ export const platformFlushClaims = (identity) => ({
|
|
|
119
121
|
*/
|
|
120
122
|
export const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
|
|
121
123
|
const pool = getPgPool(opts.pg);
|
|
124
|
+
const role = getControlRole(opts);
|
|
122
125
|
let cached = null;
|
|
123
126
|
const claims = async () => {
|
|
124
127
|
if (cached)
|
|
125
128
|
return cached;
|
|
126
|
-
cached = platformFlushClaims(await resolvePlatformFlushIdentity(pool, recorderOpts.principalName));
|
|
129
|
+
cached = platformFlushClaims(await resolvePlatformFlushIdentity(controlPlanePool(pool, role), recorderOpts.principalName));
|
|
127
130
|
log.info(`[refusals] flushing as '${recorderOpts.principalName ?? PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
|
|
128
131
|
return cached;
|
|
129
132
|
};
|
|
130
133
|
return new RefusalRecorder({
|
|
131
|
-
sink: createRecordRefusalsSink({ pool, claims }),
|
|
134
|
+
sink: createRecordRefusalsSink({ pool, role, claims }),
|
|
132
135
|
intervalMs: recorderOpts.intervalMs,
|
|
133
136
|
jitterMs: recorderOpts.jitterMs,
|
|
134
137
|
maxKeys: recorderOpts.maxKeys
|
package/esm/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import graphqlUpload from 'graphql-upload';
|
|
|
11
11
|
import { getPgPool } from 'pg-cache';
|
|
12
12
|
import requestIp from 'request-ip';
|
|
13
13
|
import { createAgenticRouter } from './agentic';
|
|
14
|
+
import { getControlRole } from './control-role';
|
|
14
15
|
import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
|
|
15
16
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
16
17
|
import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability';
|
|
@@ -76,6 +77,7 @@ class Server {
|
|
|
76
77
|
constructor(opts) {
|
|
77
78
|
this.opts = getEnvOptions(opts);
|
|
78
79
|
const effectiveOpts = this.opts;
|
|
80
|
+
const controlRole = getControlRole(effectiveOpts);
|
|
79
81
|
const observabilityRequested = isGraphqlObservabilityRequested();
|
|
80
82
|
const observabilityEnabled = isGraphqlObservabilityEnabled(effectiveOpts.server?.host);
|
|
81
83
|
const app = express();
|
|
@@ -145,7 +147,8 @@ class Server {
|
|
|
145
147
|
app.use(createContextMiddleware({
|
|
146
148
|
pg: effectiveOpts.pg,
|
|
147
149
|
loaders: createDefaultRegistry(),
|
|
148
|
-
routingSchema: getRoutingSchema(effectiveOpts)
|
|
150
|
+
routingSchema: getRoutingSchema(effectiveOpts),
|
|
151
|
+
controlRole
|
|
149
152
|
}));
|
|
150
153
|
// A suspended (or unknown) database is refused before anything is spent on
|
|
151
154
|
// the request; billing and platform admins set that state, the loader reads it.
|
package/middleware/api.js
CHANGED
|
@@ -10,6 +10,8 @@ const url_domains_1 = require("@constructive-io/url-domains");
|
|
|
10
10
|
const logger_1 = require("@pgpmjs/logger");
|
|
11
11
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
12
12
|
const pg_cache_1 = require("pg-cache");
|
|
13
|
+
const pg_query_context_1 = require("pg-query-context");
|
|
14
|
+
const control_role_1 = require("../control-role");
|
|
13
15
|
const _50x_1 = __importDefault(require("../errors/50x"));
|
|
14
16
|
const _404_message_1 = __importDefault(require("../errors/404-message"));
|
|
15
17
|
const routing_1 = require("./routing");
|
|
@@ -54,7 +56,7 @@ const buildLoaderContext = (routingPool, opts, row) => {
|
|
|
54
56
|
return {
|
|
55
57
|
routingPool,
|
|
56
58
|
routingSchema: (0, routing_1.getRoutingSchema)(opts),
|
|
57
|
-
tenantPool: (0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname }),
|
|
59
|
+
tenantPool: (0, pg_query_context_1.controlPlanePool)((0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname }), routingPool.role),
|
|
58
60
|
databaseId: row.database_id,
|
|
59
61
|
apiId: row.api_id,
|
|
60
62
|
dbname
|
|
@@ -268,7 +270,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
268
270
|
// Main Resolution Function
|
|
269
271
|
// =============================================================================
|
|
270
272
|
const getApiConfig = async (opts, req) => {
|
|
271
|
-
const pool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
273
|
+
const pool = (0, pg_query_context_1.controlPlanePool)((0, pg_cache_1.getPgPool)(opts.pg), (0, control_role_1.getControlRole)(opts));
|
|
272
274
|
const { domain, subdomains } = getUrlDomains(req);
|
|
273
275
|
const subdomain = (0, exports.getSubdomain)(subdomains);
|
|
274
276
|
const cacheKey = (0, exports.getSvcKey)(opts, req);
|
package/middleware/auth.js
CHANGED
package/middleware/flush.js
CHANGED
|
@@ -7,6 +7,8 @@ const server_utils_1 = require("@pgpmjs/server-utils");
|
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
8
|
const graphile_cache_1 = require("graphile-cache");
|
|
9
9
|
const pg_cache_1 = require("pg-cache");
|
|
10
|
+
const pg_query_context_1 = require("pg-query-context");
|
|
11
|
+
const control_role_1 = require("../control-role");
|
|
10
12
|
const routing_1 = require("./routing");
|
|
11
13
|
const log = new logger_1.Logger('flush');
|
|
12
14
|
const bearerToken = (req) => {
|
|
@@ -51,7 +53,7 @@ const createFlushMiddleware = (opts) => {
|
|
|
51
53
|
};
|
|
52
54
|
exports.createFlushMiddleware = createFlushMiddleware;
|
|
53
55
|
const flushService = async (opts, databaseId) => {
|
|
54
|
-
const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
56
|
+
const pgPool = (0, pg_query_context_1.controlPlanePool)((0, pg_cache_1.getPgPool)(opts.pg), (0, control_role_1.getControlRole)(opts));
|
|
55
57
|
log.info('flushing db ' + databaseId);
|
|
56
58
|
const api = new RegExp(`^api:${databaseId}:.*`);
|
|
57
59
|
const schemata = new RegExp(`^schemata:${databaseId}:.*`);
|
package/middleware/graphile.js
CHANGED
|
@@ -13,6 +13,8 @@ const graphile_function_bindings_1 = require("graphile-function-bindings");
|
|
|
13
13
|
const graphile_settings_1 = require("graphile-settings");
|
|
14
14
|
const pg_cache_1 = require("pg-cache");
|
|
15
15
|
const pg_env_1 = require("pg-env");
|
|
16
|
+
const pg_query_context_1 = require("pg-query-context");
|
|
17
|
+
const control_role_1 = require("../control-role");
|
|
16
18
|
const observability_1 = require("../diagnostics/observability");
|
|
17
19
|
const api_errors_1 = require("../errors/api-errors");
|
|
18
20
|
const graphql_response_1 = require("../errors/graphql-response");
|
|
@@ -62,7 +64,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
62
64
|
* plugin preset. Without settings the default preset is used
|
|
63
65
|
* (everything on except aggregates).
|
|
64
66
|
*/
|
|
65
|
-
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
67
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, controlRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
66
68
|
const introspection = await (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
67
69
|
const configuredPreset = graphileOptions?.preset ?? {};
|
|
68
70
|
const grafastCachePreset = (0, graphile_settings_1.createGrafastCacheLimitsPreset)(graphileOptions?.grafastCache);
|
|
@@ -93,6 +95,7 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
93
95
|
? [
|
|
94
96
|
(0, graphile_function_bindings_1.createFunctionBindingsPlugin)({
|
|
95
97
|
apiId,
|
|
98
|
+
role: controlRole,
|
|
96
99
|
modules: compute.modules.map((m) => ({
|
|
97
100
|
computeSchema: m.schemaName,
|
|
98
101
|
bindingsTable: m.bindingsTableName,
|
|
@@ -317,7 +320,7 @@ const graphile = (opts) => {
|
|
|
317
320
|
// concurrent requests coalesce while optional plugin modules load.
|
|
318
321
|
const creationPromise = (async () => {
|
|
319
322
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
320
|
-
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
323
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, (0, control_role_1.getControlRole)(opts), opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
321
324
|
return (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
322
325
|
cacheKey: key,
|
|
323
326
|
serviceKey: key,
|
|
@@ -325,7 +328,8 @@ const graphile = (opts) => {
|
|
|
325
328
|
}, () => (0, graphile_cache_1.createGraphileInstance)({
|
|
326
329
|
preset,
|
|
327
330
|
cacheKey: key,
|
|
328
|
-
enableRealtime: api.databaseSettings?.enableRealtime
|
|
331
|
+
enableRealtime: api.databaseSettings?.enableRealtime,
|
|
332
|
+
realtimePool: (0, pg_query_context_1.controlPlanePool)(pool, (0, control_role_1.getControlRole)(opts))
|
|
329
333
|
}), { enabled: observabilityEnabled });
|
|
330
334
|
})();
|
|
331
335
|
creating.set(key, creationPromise);
|
package/middleware/routing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ControlPlaneQueryable } from 'pg-query-context';
|
|
2
2
|
import { ApiOptions, ApiStructure } from '../types';
|
|
3
3
|
/**
|
|
4
4
|
* The role an API row names is the role the server will SET ROLE to; a row
|
|
@@ -41,7 +41,7 @@ export declare const isValidSchemaName: (name: string) => boolean;
|
|
|
41
41
|
* resolver is not installed in the target database — in both cases the caller
|
|
42
42
|
* treats it as a hard no-match (→ 404); there is no legacy fallback.
|
|
43
43
|
*/
|
|
44
|
-
export declare const resolveRoute: (pool:
|
|
44
|
+
export declare const resolveRoute: (pool: ControlPlaneQueryable, schema: string, host: string) => Promise<ResolvedRoute | null>;
|
|
45
45
|
/**
|
|
46
46
|
* Map a resolved api-target route onto the ApiStructure shape consumed by the
|
|
47
47
|
* rest of the middleware chain. Returns null when the route target is not an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.30.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"@agentic-kit/ollama": "2.15.0",
|
|
44
44
|
"@constructive-io/csrf": "^0.30.1",
|
|
45
45
|
"@constructive-io/errors": "^0.24.0",
|
|
46
|
-
"@constructive-io/express-context": "^0.
|
|
47
|
-
"@constructive-io/graphql-env": "^3.
|
|
48
|
-
"@constructive-io/graphql-types": "^3.
|
|
46
|
+
"@constructive-io/express-context": "^0.34.0",
|
|
47
|
+
"@constructive-io/graphql-env": "^3.36.0",
|
|
48
|
+
"@constructive-io/graphql-types": "^3.35.0",
|
|
49
49
|
"@constructive-io/llm-env": "^0.15.0",
|
|
50
50
|
"@constructive-io/query-builder": "^3.16.9",
|
|
51
51
|
"@constructive-io/s3-utils": "^2.34.0",
|
|
@@ -65,12 +65,12 @@
|
|
|
65
65
|
"grafserv": "1.0.1",
|
|
66
66
|
"graphile-build": "5.1.1",
|
|
67
67
|
"graphile-build-pg": "5.1.3",
|
|
68
|
-
"graphile-cache": "^4.
|
|
68
|
+
"graphile-cache": "^4.15.0",
|
|
69
69
|
"graphile-config": "1.1.0",
|
|
70
|
-
"graphile-function-bindings": "^1.17.
|
|
70
|
+
"graphile-function-bindings": "^1.17.10",
|
|
71
71
|
"graphile-plugin-utils": "^1.13.0",
|
|
72
|
-
"graphile-scoped-introspection": "^0.4.
|
|
73
|
-
"graphile-settings": "^6.25.
|
|
72
|
+
"graphile-scoped-introspection": "^0.4.10",
|
|
73
|
+
"graphile-settings": "^6.25.2",
|
|
74
74
|
"graphile-utils": "5.0.3",
|
|
75
75
|
"graphql": "16.13.0",
|
|
76
76
|
"graphql-upload": "^13.0.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"pg": "^8.21.0",
|
|
79
79
|
"pg-cache": "^3.29.7",
|
|
80
80
|
"pg-env": "^1.32.0",
|
|
81
|
-
"pg-query-context": "^2.
|
|
81
|
+
"pg-query-context": "^2.32.0",
|
|
82
82
|
"pg-sql2": "5.0.1",
|
|
83
83
|
"postgraphile": "5.1.4",
|
|
84
84
|
"request-ip": "^3.3.0"
|
|
@@ -93,11 +93,11 @@
|
|
|
93
93
|
"@types/request-ip": "^0.0.41",
|
|
94
94
|
"@types/supertest": "^7.2.1",
|
|
95
95
|
"cookie-parser": "^1.4.7",
|
|
96
|
-
"graphile-test": "5.17.
|
|
96
|
+
"graphile-test": "5.17.10",
|
|
97
97
|
"makage": "^0.8.0",
|
|
98
98
|
"nodemon": "^3.1.14",
|
|
99
99
|
"supertest": "^7.2.2",
|
|
100
100
|
"ts-node": "^10.9.2"
|
|
101
101
|
},
|
|
102
|
-
"gitHead": "
|
|
102
|
+
"gitHead": "2f506a4a4062b4bd92979c02249e0fee14af4043"
|
|
103
103
|
}
|
package/refusals/recorder.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { RefusalReason, RefusalRecorderStats } from '@constructive-io/expre
|
|
|
3
3
|
import { RefusalRecorder } from '@constructive-io/express-context';
|
|
4
4
|
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
5
5
|
import type { Request } from 'express';
|
|
6
|
-
import type
|
|
6
|
+
import { type ControlPlaneQueryable } from 'pg-query-context';
|
|
7
7
|
/**
|
|
8
8
|
* refusals/recorder — the GraphQL lane's `RefusalRecorder`.
|
|
9
9
|
*
|
|
@@ -61,7 +61,7 @@ interface PlatformFlushIdentity {
|
|
|
61
61
|
* `platform-bootstrap` service principal's user row. Throws — with the row
|
|
62
62
|
* that was missing named — rather than returning a partial identity.
|
|
63
63
|
*/
|
|
64
|
-
export declare const resolvePlatformFlushIdentity: (pool:
|
|
64
|
+
export declare const resolvePlatformFlushIdentity: (pool: ControlPlaneQueryable, principalName?: string) => Promise<PlatformFlushIdentity>;
|
|
65
65
|
export declare const platformFlushClaims: (identity: PlatformFlushIdentity) => Record<string, string>;
|
|
66
66
|
export interface PlatformRefusalRecorderOptions {
|
|
67
67
|
intervalMs?: number;
|
package/refusals/recorder.js
CHANGED
|
@@ -5,6 +5,8 @@ require("../middleware/types"); // for Request type
|
|
|
5
5
|
const express_context_1 = require("@constructive-io/express-context");
|
|
6
6
|
const logger_1 = require("@pgpmjs/logger");
|
|
7
7
|
const pg_cache_1 = require("pg-cache");
|
|
8
|
+
const pg_query_context_1 = require("pg-query-context");
|
|
9
|
+
const control_role_1 = require("../control-role");
|
|
8
10
|
const log = new logger_1.Logger('refusals');
|
|
9
11
|
/**
|
|
10
12
|
* refusals/recorder — the GraphQL lane's `RefusalRecorder`.
|
|
@@ -129,16 +131,17 @@ exports.platformFlushClaims = platformFlushClaims;
|
|
|
129
131
|
*/
|
|
130
132
|
const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
|
|
131
133
|
const pool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
134
|
+
const role = (0, control_role_1.getControlRole)(opts);
|
|
132
135
|
let cached = null;
|
|
133
136
|
const claims = async () => {
|
|
134
137
|
if (cached)
|
|
135
138
|
return cached;
|
|
136
|
-
cached = (0, exports.platformFlushClaims)(await (0, exports.resolvePlatformFlushIdentity)(pool, recorderOpts.principalName));
|
|
139
|
+
cached = (0, exports.platformFlushClaims)(await (0, exports.resolvePlatformFlushIdentity)((0, pg_query_context_1.controlPlanePool)(pool, role), recorderOpts.principalName));
|
|
137
140
|
log.info(`[refusals] flushing as '${recorderOpts.principalName ?? exports.PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
|
|
138
141
|
return cached;
|
|
139
142
|
};
|
|
140
143
|
return new express_context_1.RefusalRecorder({
|
|
141
|
-
sink: (0, express_context_1.createRecordRefusalsSink)({ pool, claims }),
|
|
144
|
+
sink: (0, express_context_1.createRecordRefusalsSink)({ pool, role, claims }),
|
|
142
145
|
intervalMs: recorderOpts.intervalMs,
|
|
143
146
|
jitterMs: recorderOpts.jitterMs,
|
|
144
147
|
maxKeys: recorderOpts.maxKeys
|
package/server.js
CHANGED
|
@@ -17,6 +17,7 @@ const graphql_upload_1 = __importDefault(require("graphql-upload"));
|
|
|
17
17
|
const pg_cache_1 = require("pg-cache");
|
|
18
18
|
const request_ip_1 = __importDefault(require("request-ip"));
|
|
19
19
|
const agentic_1 = require("./agentic");
|
|
20
|
+
const control_role_1 = require("./control-role");
|
|
20
21
|
const debug_db_snapshot_1 = require("./diagnostics/debug-db-snapshot");
|
|
21
22
|
const debug_sampler_1 = require("./diagnostics/debug-sampler");
|
|
22
23
|
const observability_1 = require("./diagnostics/observability");
|
|
@@ -83,6 +84,7 @@ class Server {
|
|
|
83
84
|
constructor(opts) {
|
|
84
85
|
this.opts = (0, graphql_env_1.getEnvOptions)(opts);
|
|
85
86
|
const effectiveOpts = this.opts;
|
|
87
|
+
const controlRole = (0, control_role_1.getControlRole)(effectiveOpts);
|
|
86
88
|
const observabilityRequested = (0, observability_1.isGraphqlObservabilityRequested)();
|
|
87
89
|
const observabilityEnabled = (0, observability_1.isGraphqlObservabilityEnabled)(effectiveOpts.server?.host);
|
|
88
90
|
const app = (0, express_1.default)();
|
|
@@ -152,7 +154,8 @@ class Server {
|
|
|
152
154
|
app.use((0, express_context_1.createContextMiddleware)({
|
|
153
155
|
pg: effectiveOpts.pg,
|
|
154
156
|
loaders: (0, express_context_1.createDefaultRegistry)(),
|
|
155
|
-
routingSchema: (0, routing_1.getRoutingSchema)(effectiveOpts)
|
|
157
|
+
routingSchema: (0, routing_1.getRoutingSchema)(effectiveOpts),
|
|
158
|
+
controlRole
|
|
156
159
|
}));
|
|
157
160
|
// A suspended (or unknown) database is refused before anything is spent on
|
|
158
161
|
// the request; billing and platform admins set that state, the loader reads it.
|