@constructive-io/graphql-server 5.29.1 → 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/plugins/webauthn-plugin.js +12 -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/plugins/webauthn-plugin.js +12 -3
- 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);
|
|
@@ -242,20 +242,27 @@ export const WebauthnPlugin = {
|
|
|
242
242
|
if (name !== null && name.length > MAX_NAME_LENGTH) {
|
|
243
243
|
fail('BAD_USER_INPUT', `name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
244
244
|
}
|
|
245
|
+
let verifiedChallenge = null;
|
|
245
246
|
const verified = await withVerifierLane(withPgClient, pgSettings, (tx) => verifying(() => verifyRegistrationResponse({
|
|
246
247
|
response,
|
|
247
|
-
expectedChallenge: (challenge) =>
|
|
248
|
+
expectedChallenge: async (challenge) => {
|
|
249
|
+
const matches = await challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null);
|
|
250
|
+
if (matches)
|
|
251
|
+
verifiedChallenge = challenge;
|
|
252
|
+
return matches;
|
|
253
|
+
},
|
|
248
254
|
expectedOrigin: settings.originAllowlist,
|
|
249
255
|
expectedRPID: settings.rpId,
|
|
250
256
|
requireUserVerification: settings.requireUserVerification
|
|
251
257
|
})));
|
|
252
|
-
if (!verified.verified)
|
|
258
|
+
if (!verified.verified || verifiedChallenge === null) {
|
|
253
259
|
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn registration verification failed');
|
|
260
|
+
}
|
|
254
261
|
const { credential, credentialDeviceType, credentialBackedUp } = verified.registrationInfo;
|
|
255
262
|
const credentialId = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
256
263
|
const { rows } = await tx.query({
|
|
257
264
|
text: `SELECT ${procedure(settings, 'webauthn_finish_registration')}(
|
|
258
|
-
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
265
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
|
259
266
|
) AS id`,
|
|
260
267
|
values: [
|
|
261
268
|
credential.id,
|
|
@@ -269,6 +276,8 @@ export const WebauthnPlugin = {
|
|
|
269
276
|
credentialBackedUp,
|
|
270
277
|
webauthnUserId,
|
|
271
278
|
userId,
|
|
279
|
+
// Consume exactly the challenge the attestation was verified against.
|
|
280
|
+
verifiedChallenge,
|
|
272
281
|
name
|
|
273
282
|
]
|
|
274
283
|
});
|
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
|
}
|
|
@@ -245,20 +245,27 @@ exports.WebauthnPlugin = {
|
|
|
245
245
|
if (name !== null && name.length > MAX_NAME_LENGTH) {
|
|
246
246
|
fail('BAD_USER_INPUT', `name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
247
247
|
}
|
|
248
|
+
let verifiedChallenge = null;
|
|
248
249
|
const verified = await withVerifierLane(withPgClient, pgSettings, (tx) => verifying(() => (0, server_1.verifyRegistrationResponse)({
|
|
249
250
|
response,
|
|
250
|
-
expectedChallenge: (challenge) =>
|
|
251
|
+
expectedChallenge: async (challenge) => {
|
|
252
|
+
const matches = await challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null);
|
|
253
|
+
if (matches)
|
|
254
|
+
verifiedChallenge = challenge;
|
|
255
|
+
return matches;
|
|
256
|
+
},
|
|
251
257
|
expectedOrigin: settings.originAllowlist,
|
|
252
258
|
expectedRPID: settings.rpId,
|
|
253
259
|
requireUserVerification: settings.requireUserVerification
|
|
254
260
|
})));
|
|
255
|
-
if (!verified.verified)
|
|
261
|
+
if (!verified.verified || verifiedChallenge === null) {
|
|
256
262
|
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn registration verification failed');
|
|
263
|
+
}
|
|
257
264
|
const { credential, credentialDeviceType, credentialBackedUp } = verified.registrationInfo;
|
|
258
265
|
const credentialId = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
259
266
|
const { rows } = await tx.query({
|
|
260
267
|
text: `SELECT ${procedure(settings, 'webauthn_finish_registration')}(
|
|
261
|
-
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
268
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
|
262
269
|
) AS id`,
|
|
263
270
|
values: [
|
|
264
271
|
credential.id,
|
|
@@ -272,6 +279,8 @@ exports.WebauthnPlugin = {
|
|
|
272
279
|
credentialBackedUp,
|
|
273
280
|
webauthnUserId,
|
|
274
281
|
userId,
|
|
282
|
+
// Consume exactly the challenge the attestation was verified against.
|
|
283
|
+
verifiedChallenge,
|
|
275
284
|
name
|
|
276
285
|
]
|
|
277
286
|
});
|
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.
|