@constructive-io/graphql-server 5.22.0 → 5.24.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.
@@ -12,6 +12,7 @@ import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
12
12
  import { HandlerCreationError } from '../errors/api-errors';
13
13
  import { respondWithGraphQLError } from '../errors/graphql-response';
14
14
  import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
15
+ import { createErrorEventsPlugin } from '../plugins/error-events-plugin';
15
16
  import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
16
17
  import { maskError } from './mask-error';
17
18
  import { observeGraphileBuild } from './observability/graphile-build-stats';
@@ -61,6 +62,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, datab
61
62
  plugins: [
62
63
  AuthCookiePlugin,
63
64
  RequestProtectionPlugin,
65
+ createErrorEventsPlugin(pool),
64
66
  // Only registered when the compute module is provisioned for this
65
67
  // database — all schema/table names come from the constructive
66
68
  // metaschema (express-context compute module loader); the plugin has
@@ -51,7 +51,7 @@ const BAD_USER_INPUT = 'BAD_USER_INPUT';
51
51
  * underlying pg error at `originalError`). We parse `originalError` first so we
52
52
  * can recover the structured code, then fall back to the GraphQL error itself.
53
53
  */
54
- const normalizeError = (error) => {
54
+ export const normalizeError = (error) => {
55
55
  const original = error.originalError;
56
56
  const fromOriginal = original ? parse(original) : null;
57
57
  const parsed = fromOriginal?.code ? fromOriginal : parse(error);
@@ -0,0 +1,79 @@
1
+ import '../middleware/types'; // for Request type
2
+ import { Logger } from '@pgpmjs/logger';
3
+ import { getOperationAST } from 'graphql';
4
+ import { escapeIdentifier } from 'pg';
5
+ import { withPgClient } from 'pg-query-context';
6
+ import { normalizeError } from '../middleware/mask-error';
7
+ const log = new Logger('error-events');
8
+ const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
9
+ /**
10
+ * The first structured, public-classified registry code among the errors.
11
+ * Internal/unknown errors are bugs, not refusals: they are masked and logged
12
+ * by `maskError` and never recorded as tenant events.
13
+ */
14
+ const refusalCode = (errors) => {
15
+ for (const error of errors ?? []) {
16
+ const { code, class: errorClass } = normalizeError(error);
17
+ if (code && errorClass === 'public')
18
+ return code;
19
+ }
20
+ return undefined;
21
+ };
22
+ export const recordErrorSql = (events) => {
23
+ if (!events.recordError) {
24
+ throw new Error(`events module ${events.privateSchemaName} has no record_error function`);
25
+ }
26
+ return `SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordError)}($1, $2::uuid, $3::jsonb)`;
27
+ };
28
+ /**
29
+ * Records a refused authenticated mutation as an event named after the error
30
+ * code raised by `errors.raise_error`. The refusal rolled back the mutation's
31
+ * own transaction, so the event is written afterwards in a fresh transaction
32
+ * under the same request claims, via the tenant's events module
33
+ * `record_error`, which classifies the code as an error that earns no ladder
34
+ * progress.
35
+ *
36
+ * The server carries no policy: the code is the event, and the database decides
37
+ * what it means — a ladder's `revoked_by` names the code directly (e.g.
38
+ * PRINCIPAL_CHILD_WIDENS demoting a principal on the trust ladder). Endpoints
39
+ * without an events module record nothing. Unauthenticated requests are never
40
+ * recorded, so anonymous traffic cannot drive writes. The client response is
41
+ * never altered.
42
+ */
43
+ export const createErrorEventsPlugin = (pool) => ({
44
+ name: 'ErrorEventsPlugin',
45
+ version: '0.0.0',
46
+ description: 'Records a refused authenticated mutation as its error code through the tenant events module.',
47
+ grafast: {
48
+ middleware: {
49
+ async execute(next, event) {
50
+ const result = await next();
51
+ if (Symbol.asyncIterator in result)
52
+ return result;
53
+ const { args } = event;
54
+ const req = getExpressRequest(args.requestContext);
55
+ const actorId = req?.token?.principal_id ?? req?.token?.user_id;
56
+ if (!actorId)
57
+ return result;
58
+ if (getOperationAST(args.document, args.operationName)?.operation !== 'mutation')
59
+ return result;
60
+ const code = refusalCode(result.errors);
61
+ if (!code)
62
+ return result;
63
+ const pgSettings = args.contextValue?.pgSettings;
64
+ const label = req.requestId ? `[${req.requestId}]` : '[req]';
65
+ try {
66
+ const events = await req.constructive?.useModule('events');
67
+ if (!events || !pgSettings)
68
+ return result;
69
+ const operation = args.operationName ?? getOperationAST(args.document)?.name?.value ?? null;
70
+ await withPgClient(pool, pgSettings, (client) => client.query(recordErrorSql(events), [code, actorId, JSON.stringify({ operation })]));
71
+ }
72
+ catch (err) {
73
+ log.error(`${label} failed to record refusal ${code} for ${actorId}: ${err instanceof Error ? err.message : String(err)}`);
74
+ }
75
+ return result;
76
+ }
77
+ }
78
+ }
79
+ });
@@ -18,6 +18,7 @@ const observability_1 = require("../diagnostics/observability");
18
18
  const api_errors_1 = require("../errors/api-errors");
19
19
  const graphql_response_1 = require("../errors/graphql-response");
20
20
  const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
21
+ const error_events_plugin_1 = require("../plugins/error-events-plugin");
21
22
  const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
22
23
  const mask_error_1 = require("./mask-error");
23
24
  const graphile_build_stats_1 = require("./observability/graphile-build-stats");
@@ -67,6 +68,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, datab
67
68
  plugins: [
68
69
  auth_cookie_plugin_1.AuthCookiePlugin,
69
70
  request_protection_plugin_1.RequestProtectionPlugin,
71
+ (0, error_events_plugin_1.createErrorEventsPlugin)(pool),
70
72
  // Only registered when the compute module is provisioned for this
71
73
  // database — all schema/table names come from the constructive
72
74
  // metaschema (express-context compute module loader); the plugin has
@@ -1,4 +1,18 @@
1
+ import { type ErrorContext } from '@constructive-io/errors';
1
2
  import { type GraphQLError, type GraphQLFormattedError } from 'graphql';
3
+ /**
4
+ * Normalize any GraphQL/database error into a canonical Constructive shape.
5
+ *
6
+ * Database errors surface through Grafast without a populated `extensions.code`
7
+ * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
8
+ * underlying pg error at `originalError`). We parse `originalError` first so we
9
+ * can recover the structured code, then fall back to the GraphQL error itself.
10
+ */
11
+ export declare const normalizeError: (error: GraphQLError) => {
12
+ code: string | null;
13
+ context: ErrorContext;
14
+ class: "public" | "internal";
15
+ };
2
16
  /**
3
17
  * Production-aware error handling backed by `@constructive-io/errors`.
4
18
  *
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.maskError = void 0;
6
+ exports.maskError = exports.normalizeError = void 0;
7
7
  const node_crypto_1 = __importDefault(require("node:crypto"));
8
8
  const errors_1 = require("@constructive-io/errors");
9
9
  const env_1 = require("@pgpmjs/env");
@@ -63,6 +63,7 @@ const normalizeError = (error) => {
63
63
  const parsed = fromOriginal?.code ? fromOriginal : (0, errors_1.parse)(error);
64
64
  return { code: parsed.code, context: parsed.context, class: parsed.class };
65
65
  };
66
+ exports.normalizeError = normalizeError;
66
67
  /**
67
68
  * Production-aware error handling backed by `@constructive-io/errors`.
68
69
  *
@@ -75,7 +76,7 @@ const normalizeError = (error) => {
75
76
  * the original.
76
77
  */
77
78
  const maskError = (error) => {
78
- const { code, context, class: errorClass } = normalizeError(error);
79
+ const { code, context, class: errorClass } = (0, exports.normalizeError)(error);
79
80
  // Lift the structured code onto extensions for every recognized error so
80
81
  // clients always receive a machine-readable code (`extensions` is read-only
81
82
  // on GraphQLError, so we build a formatted error rather than mutating it).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.22.0",
3
+ "version": "5.24.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -44,7 +44,7 @@
44
44
  "@agentic-kit/ollama": "2.14.0",
45
45
  "@constructive-io/csrf": "^0.29.1",
46
46
  "@constructive-io/errors": "^0.13.0",
47
- "@constructive-io/express-context": "^0.28.0",
47
+ "@constructive-io/express-context": "^0.30.0",
48
48
  "@constructive-io/graphql-env": "^3.32.0",
49
49
  "@constructive-io/graphql-types": "^3.31.0",
50
50
  "@constructive-io/llm-env": "^0.14.1",
@@ -67,7 +67,7 @@
67
67
  "graphile-cache": "^4.12.4",
68
68
  "graphile-config": "1.1.0",
69
69
  "graphile-function-bindings": "^1.14.5",
70
- "graphile-settings": "^6.21.5",
70
+ "graphile-settings": "^6.21.7",
71
71
  "graphile-utils": "5.0.3",
72
72
  "graphql": "16.13.0",
73
73
  "graphql-upload": "^13.0.0",
@@ -96,5 +96,5 @@
96
96
  "supertest": "^7.2.2",
97
97
  "ts-node": "^10.9.2"
98
98
  },
99
- "gitHead": "01e073830677c0587bab564b7d575a06b5156f0c"
99
+ "gitHead": "10d3a11478d17a20c80da5a827f8e8da4facfc80"
100
100
  }
@@ -0,0 +1,21 @@
1
+ import '../middleware/types';
2
+ import type { EventsConfig } from '@constructive-io/express-context';
3
+ import type { GraphileConfig } from 'graphile-config';
4
+ import { type Pool } from 'pg';
5
+ export declare const recordErrorSql: (events: EventsConfig) => string;
6
+ /**
7
+ * Records a refused authenticated mutation as an event named after the error
8
+ * code raised by `errors.raise_error`. The refusal rolled back the mutation's
9
+ * own transaction, so the event is written afterwards in a fresh transaction
10
+ * under the same request claims, via the tenant's events module
11
+ * `record_error`, which classifies the code as an error that earns no ladder
12
+ * progress.
13
+ *
14
+ * The server carries no policy: the code is the event, and the database decides
15
+ * what it means — a ladder's `revoked_by` names the code directly (e.g.
16
+ * PRINCIPAL_CHILD_WIDENS demoting a principal on the trust ladder). Endpoints
17
+ * without an events module record nothing. Unauthenticated requests are never
18
+ * recorded, so anonymous traffic cannot drive writes. The client response is
19
+ * never altered.
20
+ */
21
+ export declare const createErrorEventsPlugin: (pool: Pool) => GraphileConfig.Plugin;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createErrorEventsPlugin = exports.recordErrorSql = void 0;
4
+ require("../middleware/types"); // for Request type
5
+ const logger_1 = require("@pgpmjs/logger");
6
+ const graphql_1 = require("graphql");
7
+ const pg_1 = require("pg");
8
+ const pg_query_context_1 = require("pg-query-context");
9
+ const mask_error_1 = require("../middleware/mask-error");
10
+ const log = new logger_1.Logger('error-events');
11
+ const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
12
+ /**
13
+ * The first structured, public-classified registry code among the errors.
14
+ * Internal/unknown errors are bugs, not refusals: they are masked and logged
15
+ * by `maskError` and never recorded as tenant events.
16
+ */
17
+ const refusalCode = (errors) => {
18
+ for (const error of errors ?? []) {
19
+ const { code, class: errorClass } = (0, mask_error_1.normalizeError)(error);
20
+ if (code && errorClass === 'public')
21
+ return code;
22
+ }
23
+ return undefined;
24
+ };
25
+ const recordErrorSql = (events) => {
26
+ if (!events.recordError) {
27
+ throw new Error(`events module ${events.privateSchemaName} has no record_error function`);
28
+ }
29
+ return `SELECT ${(0, pg_1.escapeIdentifier)(events.privateSchemaName)}.${(0, pg_1.escapeIdentifier)(events.recordError)}($1, $2::uuid, $3::jsonb)`;
30
+ };
31
+ exports.recordErrorSql = recordErrorSql;
32
+ /**
33
+ * Records a refused authenticated mutation as an event named after the error
34
+ * code raised by `errors.raise_error`. The refusal rolled back the mutation's
35
+ * own transaction, so the event is written afterwards in a fresh transaction
36
+ * under the same request claims, via the tenant's events module
37
+ * `record_error`, which classifies the code as an error that earns no ladder
38
+ * progress.
39
+ *
40
+ * The server carries no policy: the code is the event, and the database decides
41
+ * what it means — a ladder's `revoked_by` names the code directly (e.g.
42
+ * PRINCIPAL_CHILD_WIDENS demoting a principal on the trust ladder). Endpoints
43
+ * without an events module record nothing. Unauthenticated requests are never
44
+ * recorded, so anonymous traffic cannot drive writes. The client response is
45
+ * never altered.
46
+ */
47
+ const createErrorEventsPlugin = (pool) => ({
48
+ name: 'ErrorEventsPlugin',
49
+ version: '0.0.0',
50
+ description: 'Records a refused authenticated mutation as its error code through the tenant events module.',
51
+ grafast: {
52
+ middleware: {
53
+ async execute(next, event) {
54
+ const result = await next();
55
+ if (Symbol.asyncIterator in result)
56
+ return result;
57
+ const { args } = event;
58
+ const req = getExpressRequest(args.requestContext);
59
+ const actorId = req?.token?.principal_id ?? req?.token?.user_id;
60
+ if (!actorId)
61
+ return result;
62
+ if ((0, graphql_1.getOperationAST)(args.document, args.operationName)?.operation !== 'mutation')
63
+ return result;
64
+ const code = refusalCode(result.errors);
65
+ if (!code)
66
+ return result;
67
+ const pgSettings = args.contextValue?.pgSettings;
68
+ const label = req.requestId ? `[${req.requestId}]` : '[req]';
69
+ try {
70
+ const events = await req.constructive?.useModule('events');
71
+ if (!events || !pgSettings)
72
+ return result;
73
+ const operation = args.operationName ?? (0, graphql_1.getOperationAST)(args.document)?.name?.value ?? null;
74
+ await (0, pg_query_context_1.withPgClient)(pool, pgSettings, (client) => client.query((0, exports.recordErrorSql)(events), [code, actorId, JSON.stringify({ operation })]));
75
+ }
76
+ catch (err) {
77
+ log.error(`${label} failed to record refusal ${code} for ${actorId}: ${err instanceof Error ? err.message : String(err)}`);
78
+ }
79
+ return result;
80
+ }
81
+ }
82
+ }
83
+ });
84
+ exports.createErrorEventsPlugin = createErrorEventsPlugin;