@constructive-io/graphql-server 5.21.0 → 5.23.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 +3 -1
- package/esm/index.js +1 -1
- package/esm/middleware/flush.js +37 -6
- package/esm/middleware/graphile.js +22 -3
- package/esm/middleware/mask-error.js +1 -1
- package/esm/plugins/error-events-plugin.js +76 -0
- package/esm/server.js +2 -2
- package/index.d.ts +1 -1
- package/index.js +2 -2
- package/middleware/flush.d.ts +8 -2
- package/middleware/flush.js +39 -8
- package/middleware/graphile.js +22 -3
- package/middleware/mask-error.d.ts +14 -0
- package/middleware/mask-error.js +3 -2
- package/middleware/types.d.ts +3 -0
- package/package.json +15 -15
- package/plugins/error-events-plugin.d.ts +19 -0
- package/plugins/error-events-plugin.js +81 -0
- package/server.js +1 -1
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 {
|
|
6
|
+
export { createFlushMiddleware, flushService } from './middleware/flush';
|
|
7
7
|
export { graphile } from './middleware/graphile';
|
package/esm/middleware/flush.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
}
|
|
16
|
-
return next();
|
|
47
|
+
};
|
|
17
48
|
};
|
|
18
49
|
export const flushService = async (opts, databaseId) => {
|
|
19
50
|
const pgPool = getPgPool(opts.pg);
|
|
@@ -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';
|
|
@@ -55,12 +56,13 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
55
56
|
* plugin preset. Without settings the default preset is used
|
|
56
57
|
* (everything on except aggregates).
|
|
57
58
|
*/
|
|
58
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
59
|
+
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
59
60
|
return {
|
|
60
61
|
extends: [createConstructivePreset(databaseSettings)],
|
|
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
|
|
@@ -84,7 +86,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
84
86
|
pgServices: [
|
|
85
87
|
makePgService({
|
|
86
88
|
pool,
|
|
87
|
-
schemas
|
|
89
|
+
schemas,
|
|
90
|
+
// Introspection runs outside any request, so it has no served role to
|
|
91
|
+
// inherit: unset, it reads the catalog as whatever role the pool
|
|
92
|
+
// connected as (a superuser in most deployments) and the schema
|
|
93
|
+
// advertises that role's reach. Naming the role keeps schema shape
|
|
94
|
+
// tied to a bounded role's grants.
|
|
95
|
+
...(introspectionRole && {
|
|
96
|
+
pgSettingsForIntrospection: { role: introspectionRole }
|
|
97
|
+
})
|
|
88
98
|
})
|
|
89
99
|
],
|
|
90
100
|
grafserv: {
|
|
@@ -139,6 +149,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
139
149
|
if (req.token.session_id) {
|
|
140
150
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
141
151
|
}
|
|
152
|
+
if (req.token.root_session_id) {
|
|
153
|
+
pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
|
|
154
|
+
}
|
|
155
|
+
if (req.token.parent_session_id) {
|
|
156
|
+
pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
|
|
157
|
+
}
|
|
158
|
+
if (req.token.intent) {
|
|
159
|
+
pgSettings['jwt.claims.intent'] = req.token.intent;
|
|
160
|
+
}
|
|
142
161
|
// Propagate credential metadata as JWT claims so PG functions
|
|
143
162
|
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
144
163
|
if (req.token.access_level) {
|
|
@@ -287,7 +306,7 @@ export const graphile = (opts) => {
|
|
|
287
306
|
const pool = getPgPool(pgConfig);
|
|
288
307
|
// Create promise and store in in-flight map BEFORE try block
|
|
289
308
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
290
|
-
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
309
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
|
|
291
310
|
const creationPromise = observeGraphileBuild({
|
|
292
311
|
cacheKey: key,
|
|
293
312
|
serviceKey: key,
|
|
@@ -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,76 @@
|
|
|
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
|
+
export const GRAPHQL_ERROR_EVENT = 'graphql.error';
|
|
9
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
10
|
+
/**
|
|
11
|
+
* The first structured, public-classified registry code among the errors.
|
|
12
|
+
* Internal/unknown errors are bugs, not refusals: they are masked and logged
|
|
13
|
+
* by `maskError` and never recorded as tenant events.
|
|
14
|
+
*/
|
|
15
|
+
const refusalCode = (errors) => {
|
|
16
|
+
for (const error of errors ?? []) {
|
|
17
|
+
const { code, class: errorClass } = normalizeError(error);
|
|
18
|
+
if (code && errorClass === 'public')
|
|
19
|
+
return code;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
};
|
|
23
|
+
export const recordEventSql = (events) => `SELECT ${escapeIdentifier(events.privateSchemaName)}.${escapeIdentifier(events.recordEvent)}($1, $2::uuid, $3::jsonb)`;
|
|
24
|
+
/**
|
|
25
|
+
* Records `graphql.error` when an authenticated mutation is refused with a
|
|
26
|
+
* structured registry code. The refusal rolled back the mutation's own
|
|
27
|
+
* transaction, so the event is written afterwards in a fresh transaction under
|
|
28
|
+
* the same request claims, via the tenant's events module `record_event`.
|
|
29
|
+
*
|
|
30
|
+
* The server carries no policy about what a code means: the database reads
|
|
31
|
+
* `payload->>'code'` (e.g. PRINCIPAL_CHILD_WIDENS demoting a principal on the
|
|
32
|
+
* trust ladder). Endpoints without an events module record nothing.
|
|
33
|
+
* Unauthenticated requests are never recorded, so anonymous traffic cannot
|
|
34
|
+
* drive writes. The client response is never altered.
|
|
35
|
+
*/
|
|
36
|
+
export const createErrorEventsPlugin = (pool) => ({
|
|
37
|
+
name: 'ErrorEventsPlugin',
|
|
38
|
+
version: '0.0.0',
|
|
39
|
+
description: 'Records graphql.error through the tenant events module when an authenticated mutation is refused.',
|
|
40
|
+
grafast: {
|
|
41
|
+
middleware: {
|
|
42
|
+
async execute(next, event) {
|
|
43
|
+
const result = await next();
|
|
44
|
+
if (Symbol.asyncIterator in result)
|
|
45
|
+
return result;
|
|
46
|
+
const { args } = event;
|
|
47
|
+
const req = getExpressRequest(args.requestContext);
|
|
48
|
+
const actorId = req?.token?.principal_id ?? req?.token?.user_id;
|
|
49
|
+
if (!actorId)
|
|
50
|
+
return result;
|
|
51
|
+
if (getOperationAST(args.document, args.operationName)?.operation !== 'mutation')
|
|
52
|
+
return result;
|
|
53
|
+
const code = refusalCode(result.errors);
|
|
54
|
+
if (!code)
|
|
55
|
+
return result;
|
|
56
|
+
const pgSettings = args.contextValue?.pgSettings;
|
|
57
|
+
const label = req.requestId ? `[${req.requestId}]` : '[req]';
|
|
58
|
+
try {
|
|
59
|
+
const events = await req.constructive?.useModule('events');
|
|
60
|
+
if (!events || !pgSettings)
|
|
61
|
+
return result;
|
|
62
|
+
const operation = args.operationName ?? getOperationAST(args.document)?.name?.value ?? null;
|
|
63
|
+
await withPgClient(pool, pgSettings, (client) => client.query(recordEventSql(events), [
|
|
64
|
+
GRAPHQL_ERROR_EVENT,
|
|
65
|
+
actorId,
|
|
66
|
+
JSON.stringify({ code, operation })
|
|
67
|
+
]));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
log.error(`${label} failed to record ${GRAPHQL_ERROR_EVENT} (${code}) for ${actorId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
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 {
|
|
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(
|
|
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 {
|
|
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.
|
|
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, "
|
|
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; } });
|
package/middleware/flush.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import './types';
|
|
2
2
|
import { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
3
|
-
import {
|
|
4
|
-
|
|
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>;
|
package/middleware/flush.js
CHANGED
|
@@ -1,24 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.flushService = exports.
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
}
|
|
19
|
-
return next();
|
|
50
|
+
};
|
|
20
51
|
};
|
|
21
|
-
exports.
|
|
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);
|
package/middleware/graphile.js
CHANGED
|
@@ -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");
|
|
@@ -61,12 +62,13 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
61
62
|
* plugin preset. Without settings the default preset is used
|
|
62
63
|
* (everything on except aggregates).
|
|
63
64
|
*/
|
|
64
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
65
|
+
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
65
66
|
return {
|
|
66
67
|
extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
|
|
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
|
|
@@ -90,7 +92,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
90
92
|
pgServices: [
|
|
91
93
|
(0, graphile_settings_1.makePgService)({
|
|
92
94
|
pool,
|
|
93
|
-
schemas
|
|
95
|
+
schemas,
|
|
96
|
+
// Introspection runs outside any request, so it has no served role to
|
|
97
|
+
// inherit: unset, it reads the catalog as whatever role the pool
|
|
98
|
+
// connected as (a superuser in most deployments) and the schema
|
|
99
|
+
// advertises that role's reach. Naming the role keeps schema shape
|
|
100
|
+
// tied to a bounded role's grants.
|
|
101
|
+
...(introspectionRole && {
|
|
102
|
+
pgSettingsForIntrospection: { role: introspectionRole }
|
|
103
|
+
})
|
|
94
104
|
})
|
|
95
105
|
],
|
|
96
106
|
grafserv: {
|
|
@@ -145,6 +155,15 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
145
155
|
if (req.token.session_id) {
|
|
146
156
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
147
157
|
}
|
|
158
|
+
if (req.token.root_session_id) {
|
|
159
|
+
pgSettings['jwt.claims.root_session_id'] = req.token.root_session_id;
|
|
160
|
+
}
|
|
161
|
+
if (req.token.parent_session_id) {
|
|
162
|
+
pgSettings['jwt.claims.parent_session_id'] = req.token.parent_session_id;
|
|
163
|
+
}
|
|
164
|
+
if (req.token.intent) {
|
|
165
|
+
pgSettings['jwt.claims.intent'] = req.token.intent;
|
|
166
|
+
}
|
|
148
167
|
// Propagate credential metadata as JWT claims so PG functions
|
|
149
168
|
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
150
169
|
if (req.token.access_level) {
|
|
@@ -293,7 +312,7 @@ const graphile = (opts) => {
|
|
|
293
312
|
const pool = (0, pg_cache_1.getPgPool)(pgConfig);
|
|
294
313
|
// Create promise and store in in-flight map BEFORE try block
|
|
295
314
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
296
|
-
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
315
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
|
|
297
316
|
const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
298
317
|
cacheKey: key,
|
|
299
318
|
serviceKey: key,
|
|
@@ -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
|
*
|
package/middleware/mask-error.js
CHANGED
|
@@ -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/middleware/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.23.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.
|
|
47
|
-
"@constructive-io/express-context": "^0.
|
|
48
|
-
"@constructive-io/graphql-env": "^3.
|
|
49
|
-
"@constructive-io/graphql-types": "^3.
|
|
46
|
+
"@constructive-io/errors": "^0.13.0",
|
|
47
|
+
"@constructive-io/express-context": "^0.29.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.
|
|
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.
|
|
55
|
+
"@pgpmjs/env": "^2.43.4",
|
|
56
56
|
"@pgpmjs/logger": "^2.25.1",
|
|
57
|
-
"@pgpmjs/server-utils": "^3.27.
|
|
58
|
-
"@pgpmjs/types": "^2.53.
|
|
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.
|
|
67
|
+
"graphile-cache": "^4.12.4",
|
|
68
68
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.14.
|
|
70
|
-
"graphile-settings": "^6.21.
|
|
69
|
+
"graphile-function-bindings": "^1.14.5",
|
|
70
|
+
"graphile-settings": "^6.21.6",
|
|
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.
|
|
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.
|
|
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": "
|
|
99
|
+
"gitHead": "86896399e80595be179738d459106cfbfbada659"
|
|
100
100
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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 GRAPHQL_ERROR_EVENT = "graphql.error";
|
|
6
|
+
export declare const recordEventSql: (events: EventsConfig) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Records `graphql.error` when an authenticated mutation is refused with a
|
|
9
|
+
* structured registry code. The refusal rolled back the mutation's own
|
|
10
|
+
* transaction, so the event is written afterwards in a fresh transaction under
|
|
11
|
+
* the same request claims, via the tenant's events module `record_event`.
|
|
12
|
+
*
|
|
13
|
+
* The server carries no policy about what a code means: the database reads
|
|
14
|
+
* `payload->>'code'` (e.g. PRINCIPAL_CHILD_WIDENS demoting a principal on the
|
|
15
|
+
* trust ladder). Endpoints without an events module record nothing.
|
|
16
|
+
* Unauthenticated requests are never recorded, so anonymous traffic cannot
|
|
17
|
+
* drive writes. The client response is never altered.
|
|
18
|
+
*/
|
|
19
|
+
export declare const createErrorEventsPlugin: (pool: Pool) => GraphileConfig.Plugin;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createErrorEventsPlugin = exports.recordEventSql = exports.GRAPHQL_ERROR_EVENT = 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
|
+
exports.GRAPHQL_ERROR_EVENT = 'graphql.error';
|
|
12
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
13
|
+
/**
|
|
14
|
+
* The first structured, public-classified registry code among the errors.
|
|
15
|
+
* Internal/unknown errors are bugs, not refusals: they are masked and logged
|
|
16
|
+
* by `maskError` and never recorded as tenant events.
|
|
17
|
+
*/
|
|
18
|
+
const refusalCode = (errors) => {
|
|
19
|
+
for (const error of errors ?? []) {
|
|
20
|
+
const { code, class: errorClass } = (0, mask_error_1.normalizeError)(error);
|
|
21
|
+
if (code && errorClass === 'public')
|
|
22
|
+
return code;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
const recordEventSql = (events) => `SELECT ${(0, pg_1.escapeIdentifier)(events.privateSchemaName)}.${(0, pg_1.escapeIdentifier)(events.recordEvent)}($1, $2::uuid, $3::jsonb)`;
|
|
27
|
+
exports.recordEventSql = recordEventSql;
|
|
28
|
+
/**
|
|
29
|
+
* Records `graphql.error` when an authenticated mutation is refused with a
|
|
30
|
+
* structured registry code. The refusal rolled back the mutation's own
|
|
31
|
+
* transaction, so the event is written afterwards in a fresh transaction under
|
|
32
|
+
* the same request claims, via the tenant's events module `record_event`.
|
|
33
|
+
*
|
|
34
|
+
* The server carries no policy about what a code means: the database reads
|
|
35
|
+
* `payload->>'code'` (e.g. PRINCIPAL_CHILD_WIDENS demoting a principal on the
|
|
36
|
+
* trust ladder). Endpoints without an events module record nothing.
|
|
37
|
+
* Unauthenticated requests are never recorded, so anonymous traffic cannot
|
|
38
|
+
* drive writes. The client response is never altered.
|
|
39
|
+
*/
|
|
40
|
+
const createErrorEventsPlugin = (pool) => ({
|
|
41
|
+
name: 'ErrorEventsPlugin',
|
|
42
|
+
version: '0.0.0',
|
|
43
|
+
description: 'Records graphql.error through the tenant events module when an authenticated mutation is refused.',
|
|
44
|
+
grafast: {
|
|
45
|
+
middleware: {
|
|
46
|
+
async execute(next, event) {
|
|
47
|
+
const result = await next();
|
|
48
|
+
if (Symbol.asyncIterator in result)
|
|
49
|
+
return result;
|
|
50
|
+
const { args } = event;
|
|
51
|
+
const req = getExpressRequest(args.requestContext);
|
|
52
|
+
const actorId = req?.token?.principal_id ?? req?.token?.user_id;
|
|
53
|
+
if (!actorId)
|
|
54
|
+
return result;
|
|
55
|
+
if ((0, graphql_1.getOperationAST)(args.document, args.operationName)?.operation !== 'mutation')
|
|
56
|
+
return result;
|
|
57
|
+
const code = refusalCode(result.errors);
|
|
58
|
+
if (!code)
|
|
59
|
+
return result;
|
|
60
|
+
const pgSettings = args.contextValue?.pgSettings;
|
|
61
|
+
const label = req.requestId ? `[${req.requestId}]` : '[req]';
|
|
62
|
+
try {
|
|
63
|
+
const events = await req.constructive?.useModule('events');
|
|
64
|
+
if (!events || !pgSettings)
|
|
65
|
+
return result;
|
|
66
|
+
const operation = args.operationName ?? (0, graphql_1.getOperationAST)(args.document)?.name?.value ?? null;
|
|
67
|
+
await (0, pg_query_context_1.withPgClient)(pool, pgSettings, (client) => client.query((0, exports.recordEventSql)(events), [
|
|
68
|
+
exports.GRAPHQL_ERROR_EVENT,
|
|
69
|
+
actorId,
|
|
70
|
+
JSON.stringify({ code, operation })
|
|
71
|
+
]));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
log.error(`${label} failed to record ${exports.GRAPHQL_ERROR_EVENT} (${code}) for ${actorId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
exports.createErrorEventsPlugin = createErrorEventsPlugin;
|
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.
|
|
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
|