@constructive-io/graphql-server 5.21.0 → 5.22.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
@@ -95,7 +95,7 @@ For the operational workflow, sampler output, and heap snapshot usage, see [docs
95
95
  - `GET /graphiql` -> GraphiQL UI
96
96
  - `GET /graphql` / `POST /graphql` -> GraphQL endpoint
97
97
  - `POST /graphql` (multipart) -> file uploads
98
- - `POST /flush` -> clears cached Graphile schema for the current API
98
+ - `POST /flush` -> clears cached Graphile schema for the current API; mounted only when `API_FLUSH_TOKEN` is set, and requires `Authorization: Bearer $API_FLUSH_TOKEN`. Without the variable the route is not served (404) — operators deploying a schema-cache flush must set it on the server and on every caller. The `LISTEN/NOTIFY` invalidation path is unaffected.
99
99
  - `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
100
100
  - `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
101
101
 
@@ -132,6 +132,8 @@ Configuration is merged from defaults, config files, and env vars via `@construc
132
132
  | `API_META_SCHEMAS` | Meta schemas to query | `routing_public,metaschema_public,metaschema_modules_public` |
133
133
  | `API_ANON_ROLE` | Anonymous role name | `administrator` |
134
134
  | `API_ROLE_NAME` | Authenticated role name | `administrator` |
135
+ | `API_FLUSH_TOKEN` | Bearer token required by `POST /flush`; route is not mounted when unset | empty |
136
+ | `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 |
135
137
  | `GRAPHQL_OBSERVABILITY_ENABLED` | Master switch for debug routes and sampler | `false` |
136
138
  | `GRAPHQL_DEBUG_SAMPLER_ENABLED` | Enables periodic NDJSON sampling when observability is on | `true` |
137
139
  | `GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS` | Sampler interval in milliseconds | `10000` |
package/esm/index.js CHANGED
@@ -3,5 +3,5 @@ export * from './server';
3
3
  export { createApiMiddleware, getApiConfig, getSubdomain } from './middleware/api';
4
4
  export { createAuthenticateMiddleware } from './middleware/auth';
5
5
  export { cors } from './middleware/cors';
6
- export { flush, flushService } from './middleware/flush';
6
+ export { createFlushMiddleware, flushService } from './middleware/flush';
7
7
  export { graphile } from './middleware/graphile';
@@ -1,19 +1,50 @@
1
1
  import './types'; // for Request type
2
2
  import { Logger } from '@pgpmjs/logger';
3
3
  import { svcCache } from '@pgpmjs/server-utils';
4
+ import { createHash, timingSafeEqual } from 'crypto';
4
5
  import { graphileCache } from 'graphile-cache';
5
6
  import { getPgPool } from 'pg-cache';
6
7
  import { getRoutingSchema, isValidSchemaName } from './routing';
7
8
  const log = new Logger('flush');
8
- export const flush = async (req, res, next) => {
9
- if (req.url === '/flush') {
10
- // TODO: check bearer for a flush / special key
9
+ const bearerToken = (req) => {
10
+ const header = req.get('authorization');
11
+ if (!header)
12
+ return null;
13
+ const [scheme, ...rest] = header.trim().split(/\s+/);
14
+ if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1)
15
+ return null;
16
+ return rest[0];
17
+ };
18
+ // Compare digests rather than the tokens themselves: timingSafeEqual requires
19
+ // equal lengths, and digests are equal-length whatever the caller presents.
20
+ const tokensMatch = (presented, expected) => timingSafeEqual(createHash('sha256').update(presented).digest(), createHash('sha256').update(expected).digest());
21
+ /**
22
+ * `/flush` drops the routing and schema caches for the request's service key,
23
+ * so it is a control-plane operation: it needs the flush secret, not a tenant
24
+ * session. Without a configured secret there is no way to authenticate the
25
+ * caller, so the route stays closed.
26
+ */
27
+ export const createFlushMiddleware = (opts) => {
28
+ const expected = opts.api?.flushToken;
29
+ return async (req, res, next) => {
30
+ if (req.url !== '/flush') {
31
+ return next();
32
+ }
33
+ if (!expected) {
34
+ log.warn('[flush] rejected: no api.flushToken configured');
35
+ res.status(404).send('Not Found');
36
+ return;
37
+ }
38
+ const presented = bearerToken(req);
39
+ if (!presented || !tokensMatch(presented, expected)) {
40
+ log.warn('[flush] rejected: invalid or missing bearer token');
41
+ res.status(401).send('Unauthorized');
42
+ return;
43
+ }
11
44
  graphileCache.delete(req.svc_key);
12
45
  svcCache.delete(req.svc_key);
13
46
  res.status(200).send('OK');
14
- return;
15
- }
16
- return next();
47
+ };
17
48
  };
18
49
  export const flushService = async (opts, databaseId) => {
19
50
  const pgPool = getPgPool(opts.pg);
@@ -55,7 +55,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
55
55
  * plugin preset. Without settings the default preset is used
56
56
  * (everything on except aggregates).
57
57
  */
58
- const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
58
+ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
59
59
  return {
60
60
  extends: [createConstructivePreset(databaseSettings)],
61
61
  plugins: [
@@ -84,7 +84,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
84
84
  pgServices: [
85
85
  makePgService({
86
86
  pool,
87
- schemas
87
+ schemas,
88
+ // Introspection runs outside any request, so it has no served role to
89
+ // inherit: unset, it reads the catalog as whatever role the pool
90
+ // connected as (a superuser in most deployments) and the schema
91
+ // advertises that role's reach. Naming the role keeps schema shape
92
+ // tied to a bounded role's grants.
93
+ ...(introspectionRole && {
94
+ pgSettingsForIntrospection: { role: introspectionRole }
95
+ })
88
96
  })
89
97
  ],
90
98
  grafserv: {
@@ -139,6 +147,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
139
147
  if (req.token.session_id) {
140
148
  pgSettings['jwt.claims.session_id'] = req.token.session_id;
141
149
  }
150
+ if (req.token.root_session_id) {
151
+ pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
152
+ }
153
+ if (req.token.parent_session_id) {
154
+ pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
155
+ }
156
+ if (req.token.intent) {
157
+ pgSettings['jwt.claims.intent'] = req.token.intent;
158
+ }
142
159
  // Propagate credential metadata as JWT claims so PG functions
143
160
  // can read them via current_setting('jwt.claims.access_level') etc.
144
161
  if (req.token.access_level) {
@@ -287,7 +304,7 @@ export const graphile = (opts) => {
287
304
  const pool = getPgPool(pgConfig);
288
305
  // Create promise and store in in-flight map BEFORE try block
289
306
  const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
290
- const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
307
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
291
308
  const creationPromise = observeGraphileBuild({
292
309
  cacheKey: key,
293
310
  serviceKey: key,
package/esm/server.js CHANGED
@@ -23,7 +23,7 @@ import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
23
23
  import { cors } from './middleware/cors';
24
24
  import { errorHandler, notFoundHandler } from './middleware/error-handler';
25
25
  import { favicon } from './middleware/favicon';
26
- import { flush, flushService } from './middleware/flush';
26
+ import { createFlushMiddleware, flushService } from './middleware/flush';
27
27
  import { createFnRouter } from './middleware/fn';
28
28
  import { graphile } from './middleware/graphile';
29
29
  import { multipartBridge } from './middleware/multipart-bridge';
@@ -191,7 +191,7 @@ class Server {
191
191
  // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
192
192
  app.use(createFnRouter());
193
193
  app.use(graphile(effectiveOpts));
194
- app.use(flush);
194
+ app.use(createFlushMiddleware(effectiveOpts));
195
195
  // Error handling - MUST be LAST
196
196
  app.use(notFoundHandler); // Catches unmatched routes (404)
197
197
  app.use(errorHandler); // Catches all thrown errors
package/index.d.ts CHANGED
@@ -2,5 +2,5 @@ export * from './server';
2
2
  export { createApiMiddleware, getApiConfig, getSubdomain } from './middleware/api';
3
3
  export { createAuthenticateMiddleware } from './middleware/auth';
4
4
  export { cors } from './middleware/cors';
5
- export { flush, flushService } from './middleware/flush';
5
+ export { createFlushMiddleware, flushService } from './middleware/flush';
6
6
  export { graphile } from './middleware/graphile';
package/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.graphile = exports.flushService = exports.flush = exports.cors = exports.createAuthenticateMiddleware = exports.getSubdomain = exports.getApiConfig = exports.createApiMiddleware = void 0;
17
+ exports.graphile = exports.flushService = exports.createFlushMiddleware = exports.cors = exports.createAuthenticateMiddleware = exports.getSubdomain = exports.getApiConfig = exports.createApiMiddleware = void 0;
18
18
  __exportStar(require("./server"), exports);
19
19
  // Export middleware for use in testing packages
20
20
  var api_1 = require("./middleware/api");
@@ -26,7 +26,7 @@ Object.defineProperty(exports, "createAuthenticateMiddleware", { enumerable: tru
26
26
  var cors_1 = require("./middleware/cors");
27
27
  Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
28
28
  var flush_1 = require("./middleware/flush");
29
- Object.defineProperty(exports, "flush", { enumerable: true, get: function () { return flush_1.flush; } });
29
+ Object.defineProperty(exports, "createFlushMiddleware", { enumerable: true, get: function () { return flush_1.createFlushMiddleware; } });
30
30
  Object.defineProperty(exports, "flushService", { enumerable: true, get: function () { return flush_1.flushService; } });
31
31
  var graphile_1 = require("./middleware/graphile");
32
32
  Object.defineProperty(exports, "graphile", { enumerable: true, get: function () { return graphile_1.graphile; } });
@@ -1,5 +1,11 @@
1
1
  import './types';
2
2
  import { ConstructiveOptions } from '@constructive-io/graphql-types';
3
- import { NextFunction, Request, Response } from 'express';
4
- export declare const flush: (req: Request, res: Response, next: NextFunction) => Promise<void>;
3
+ import { RequestHandler } from 'express';
4
+ /**
5
+ * `/flush` drops the routing and schema caches for the request's service key,
6
+ * so it is a control-plane operation: it needs the flush secret, not a tenant
7
+ * session. Without a configured secret there is no way to authenticate the
8
+ * caller, so the route stays closed.
9
+ */
10
+ export declare const createFlushMiddleware: (opts: ConstructiveOptions) => RequestHandler;
5
11
  export declare const flushService: (opts: ConstructiveOptions, databaseId: string) => Promise<void>;
@@ -1,24 +1,55 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.flushService = exports.flush = void 0;
3
+ exports.flushService = exports.createFlushMiddleware = void 0;
4
4
  require("./types"); // for Request type
5
5
  const logger_1 = require("@pgpmjs/logger");
6
6
  const server_utils_1 = require("@pgpmjs/server-utils");
7
+ const crypto_1 = require("crypto");
7
8
  const graphile_cache_1 = require("graphile-cache");
8
9
  const pg_cache_1 = require("pg-cache");
9
10
  const routing_1 = require("./routing");
10
11
  const log = new logger_1.Logger('flush');
11
- const flush = async (req, res, next) => {
12
- if (req.url === '/flush') {
13
- // TODO: check bearer for a flush / special key
12
+ const bearerToken = (req) => {
13
+ const header = req.get('authorization');
14
+ if (!header)
15
+ return null;
16
+ const [scheme, ...rest] = header.trim().split(/\s+/);
17
+ if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1)
18
+ return null;
19
+ return rest[0];
20
+ };
21
+ // Compare digests rather than the tokens themselves: timingSafeEqual requires
22
+ // equal lengths, and digests are equal-length whatever the caller presents.
23
+ const tokensMatch = (presented, expected) => (0, crypto_1.timingSafeEqual)((0, crypto_1.createHash)('sha256').update(presented).digest(), (0, crypto_1.createHash)('sha256').update(expected).digest());
24
+ /**
25
+ * `/flush` drops the routing and schema caches for the request's service key,
26
+ * so it is a control-plane operation: it needs the flush secret, not a tenant
27
+ * session. Without a configured secret there is no way to authenticate the
28
+ * caller, so the route stays closed.
29
+ */
30
+ const createFlushMiddleware = (opts) => {
31
+ const expected = opts.api?.flushToken;
32
+ return async (req, res, next) => {
33
+ if (req.url !== '/flush') {
34
+ return next();
35
+ }
36
+ if (!expected) {
37
+ log.warn('[flush] rejected: no api.flushToken configured');
38
+ res.status(404).send('Not Found');
39
+ return;
40
+ }
41
+ const presented = bearerToken(req);
42
+ if (!presented || !tokensMatch(presented, expected)) {
43
+ log.warn('[flush] rejected: invalid or missing bearer token');
44
+ res.status(401).send('Unauthorized');
45
+ return;
46
+ }
14
47
  graphile_cache_1.graphileCache.delete(req.svc_key);
15
48
  server_utils_1.svcCache.delete(req.svc_key);
16
49
  res.status(200).send('OK');
17
- return;
18
- }
19
- return next();
50
+ };
20
51
  };
21
- exports.flush = flush;
52
+ exports.createFlushMiddleware = createFlushMiddleware;
22
53
  const flushService = async (opts, databaseId) => {
23
54
  const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
24
55
  log.info('flushing db ' + databaseId);
@@ -61,7 +61,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
61
61
  * plugin preset. Without settings the default preset is used
62
62
  * (everything on except aggregates).
63
63
  */
64
- const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
64
+ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
65
65
  return {
66
66
  extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
67
67
  plugins: [
@@ -90,7 +90,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
90
90
  pgServices: [
91
91
  (0, graphile_settings_1.makePgService)({
92
92
  pool,
93
- schemas
93
+ schemas,
94
+ // Introspection runs outside any request, so it has no served role to
95
+ // inherit: unset, it reads the catalog as whatever role the pool
96
+ // connected as (a superuser in most deployments) and the schema
97
+ // advertises that role's reach. Naming the role keeps schema shape
98
+ // tied to a bounded role's grants.
99
+ ...(introspectionRole && {
100
+ pgSettingsForIntrospection: { role: introspectionRole }
101
+ })
94
102
  })
95
103
  ],
96
104
  grafserv: {
@@ -145,6 +153,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
145
153
  if (req.token.session_id) {
146
154
  pgSettings['jwt.claims.session_id'] = req.token.session_id;
147
155
  }
156
+ if (req.token.root_session_id) {
157
+ pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
158
+ }
159
+ if (req.token.parent_session_id) {
160
+ pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
161
+ }
162
+ if (req.token.intent) {
163
+ pgSettings['jwt.claims.intent'] = req.token.intent;
164
+ }
148
165
  // Propagate credential metadata as JWT claims so PG functions
149
166
  // can read them via current_setting('jwt.claims.access_level') etc.
150
167
  if (req.token.access_level) {
@@ -293,7 +310,7 @@ const graphile = (opts) => {
293
310
  const pool = (0, pg_cache_1.getPgPool)(pgConfig);
294
311
  // Create promise and store in in-flight map BEFORE try block
295
312
  const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
296
- const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
313
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
297
314
  const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
298
315
  cacheKey: key,
299
316
  serviceKey: key,
@@ -7,6 +7,9 @@ export type ConstructiveAPIToken = {
7
7
  session_id?: string;
8
8
  access_level?: string;
9
9
  kind?: string;
10
+ root_session_id?: string;
11
+ parent_session_id?: string;
12
+ intent?: string;
10
13
  [key: string]: unknown;
11
14
  };
12
15
  declare global {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.21.0",
3
+ "version": "5.22.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -43,19 +43,19 @@
43
43
  "dependencies": {
44
44
  "@agentic-kit/ollama": "2.14.0",
45
45
  "@constructive-io/csrf": "^0.29.1",
46
- "@constructive-io/errors": "^0.12.0",
47
- "@constructive-io/express-context": "^0.27.0",
48
- "@constructive-io/graphql-env": "^3.31.3",
49
- "@constructive-io/graphql-types": "^3.30.3",
46
+ "@constructive-io/errors": "^0.13.0",
47
+ "@constructive-io/express-context": "^0.28.0",
48
+ "@constructive-io/graphql-env": "^3.32.0",
49
+ "@constructive-io/graphql-types": "^3.31.0",
50
50
  "@constructive-io/llm-env": "^0.14.1",
51
- "@constructive-io/query-builder": "^3.13.4",
51
+ "@constructive-io/query-builder": "^3.13.5",
52
52
  "@constructive-io/s3-utils": "^2.33.0",
53
53
  "@constructive-io/url-domains": "^2.30.1",
54
54
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
55
- "@pgpmjs/env": "^2.43.3",
55
+ "@pgpmjs/env": "^2.43.4",
56
56
  "@pgpmjs/logger": "^2.25.1",
57
- "@pgpmjs/server-utils": "^3.27.3",
58
- "@pgpmjs/types": "^2.53.1",
57
+ "@pgpmjs/server-utils": "^3.27.4",
58
+ "@pgpmjs/types": "^2.53.2",
59
59
  "cors": "^2.8.6",
60
60
  "deepmerge": "^4.3.1",
61
61
  "express": "^5.2.1",
@@ -64,16 +64,16 @@
64
64
  "grafserv": "1.0.1",
65
65
  "graphile-build": "5.1.1",
66
66
  "graphile-build-pg": "5.1.3",
67
- "graphile-cache": "^4.12.3",
67
+ "graphile-cache": "^4.12.4",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.4",
70
- "graphile-settings": "^6.21.4",
69
+ "graphile-function-bindings": "^1.14.5",
70
+ "graphile-settings": "^6.21.5",
71
71
  "graphile-utils": "5.0.3",
72
72
  "graphql": "16.13.0",
73
73
  "graphql-upload": "^13.0.0",
74
74
  "lru-cache": "^11.2.7",
75
75
  "pg": "^8.21.0",
76
- "pg-cache": "^3.27.3",
76
+ "pg-cache": "^3.27.4",
77
77
  "pg-env": "^1.31.1",
78
78
  "pg-query-context": "^2.30.1",
79
79
  "pg-sql2": "5.0.1",
@@ -90,11 +90,11 @@
90
90
  "@types/request-ip": "^0.0.41",
91
91
  "@types/supertest": "^7.2.1",
92
92
  "cookie-parser": "^1.4.7",
93
- "graphile-test": "5.14.4",
93
+ "graphile-test": "5.14.5",
94
94
  "makage": "^0.8.0",
95
95
  "nodemon": "^3.1.14",
96
96
  "supertest": "^7.2.2",
97
97
  "ts-node": "^10.9.2"
98
98
  },
99
- "gitHead": "6e2d71a9eb4e1f49f0de0b2b594b2f811de97442"
99
+ "gitHead": "01e073830677c0587bab564b7d575a06b5156f0c"
100
100
  }
package/server.js CHANGED
@@ -198,7 +198,7 @@ class Server {
198
198
  // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
199
199
  app.use((0, fn_1.createFnRouter)());
200
200
  app.use((0, graphile_1.graphile)(effectiveOpts));
201
- app.use(flush_1.flush);
201
+ app.use((0, flush_1.createFlushMiddleware)(effectiveOpts));
202
202
  // Error handling - MUST be LAST
203
203
  app.use(error_handler_1.notFoundHandler); // Catches unmatched routes (404)
204
204
  app.use(error_handler_1.errorHandler); // Catches all thrown errors