@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 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;
@@ -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);
@@ -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);
@@ -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')) {
@@ -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) => challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null),
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
  });
@@ -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);
@@ -68,6 +68,7 @@ const createAuthenticateMiddleware = (opts) => {
68
68
  if (effectiveToken) {
69
69
  log.info(`[auth] Processing ${tokenSource} authentication`);
70
70
  const context = {
71
+ role: api.anonRole,
71
72
  'jwt.claims.ip_address': req.clientIp,
72
73
  };
73
74
  if (req.get('origin')) {
@@ -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}:.*`);
@@ -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);
@@ -1,4 +1,4 @@
1
- import { Pool } from 'pg';
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: Pool, schema: string, host: string) => Promise<ResolvedRoute | null>;
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.29.1",
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.33.7",
47
- "@constructive-io/graphql-env": "^3.35.0",
48
- "@constructive-io/graphql-types": "^3.34.0",
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.14.7",
68
+ "graphile-cache": "^4.15.0",
69
69
  "graphile-config": "1.1.0",
70
- "graphile-function-bindings": "^1.17.9",
70
+ "graphile-function-bindings": "^1.17.10",
71
71
  "graphile-plugin-utils": "^1.13.0",
72
- "graphile-scoped-introspection": "^0.4.9",
73
- "graphile-settings": "^6.25.1",
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.31.0",
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.9",
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": "850fc5e75a3a31e2db2fc28aa58377e886fe3c6a"
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) => challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null),
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
  });
@@ -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 { Pool } from 'pg';
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: Pool, principalName?: string) => Promise<PlatformFlushIdentity>;
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;
@@ -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.