@constructive-io/graphql-server 5.20.8 → 5.21.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.
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAdmissionControlMiddleware = void 0;
4
+ require("./types"); // for Request type
5
+ const errors_1 = require("@constructive-io/errors");
6
+ const express_context_1 = require("@constructive-io/express-context");
7
+ const logger_1 = require("@pgpmjs/logger");
8
+ const graphql_response_1 = require("../errors/graphql-response");
9
+ const recorder_1 = require("../refusals/recorder");
10
+ const log = new logger_1.Logger('admission');
11
+ /**
12
+ * admission-control — the gate that decides whether a GraphQL request starts.
13
+ *
14
+ * The document gate rejects a *shape* and the timeout GUCs bound a *duration*;
15
+ * neither refuses a request that is individually reasonable. This does, on two
16
+ * axes:
17
+ *
18
+ * 1. **Concurrency**, per database. The thing being protected is this
19
+ * process's PostgreSQL pool, so the counter is in-process and
20
+ * `maxConcurrentRequests` is a per-replica budget — a cluster-wide number
21
+ * would need shared state on the hot path to bound something that is not
22
+ * shared. Over budget, a request waits up to `maxQueueWaitMs` for a slot
23
+ * and is then refused; it never queues unbounded, because a queue that
24
+ * outgrows the timeout is just latency with a memory cost.
25
+ *
26
+ * 2. **Rate**, per caller per route. Keyed on the client address rather than
27
+ * the tenant, because a tenant-wide limit is spent *by* an anonymous
28
+ * flood: exhausting it takes the tenant's own API down on the attacker's
29
+ * behalf. This is abuse protection and fails closed, which is the opposite
30
+ * of a billing quota (that serves and records overage) — the two must not
31
+ * be conflated, and neither is a database write on the request path.
32
+ *
33
+ * @module middleware/admission-control
34
+ */
35
+ /** Window the per-caller rate is counted over — `rateLimitRpm` is per minute. */
36
+ const RATE_WINDOW_MS = 60_000;
37
+ /** What a request is keyed by when it carries no resolved database. */
38
+ const UNKNOWN_DATABASE = 'unknown';
39
+ const protectionOf = (req) => req.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
40
+ /**
41
+ * The route half of the rate key.
42
+ *
43
+ * Per-route rather than per-request-line so a caller cannot spread a flood
44
+ * across query strings, and so a cheap route's traffic does not spend the
45
+ * budget an expensive one needs.
46
+ */
47
+ const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
48
+ /** Seconds for a `Retry-After` header — the smallest honest whole number. */
49
+ const retryAfterSeconds = (ms) => String(Math.max(Math.ceil(ms / 1000), 1));
50
+ /**
51
+ * How far back through `X-Forwarded-For` to believe.
52
+ *
53
+ * `req.clientIp` and `req.ip` are both unusable as a limiter key here: this
54
+ * server sets `trust proxy` to a predicate that returns true unconditionally,
55
+ * and `request-ip` reads the *leftmost* forwarded entry regardless — so either
56
+ * one hands a caller a fresh key per request for the cost of a header. The
57
+ * fallback to 1 hop exists because the opposite failure is just as bad: behind
58
+ * an ingress with no hop count configured, every caller resolves to the
59
+ * ingress's address and one abuser throttles the whole tenant.
60
+ */
61
+ const resolveHops = (req, configured) => {
62
+ if (typeof configured === 'number')
63
+ return configured;
64
+ const fromEnv = (0, express_context_1.trustedProxyHops)();
65
+ if (fromEnv > 0)
66
+ return fromEnv;
67
+ return req.app?.get('trust proxy') ? 1 : 0;
68
+ };
69
+ /**
70
+ * Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
71
+ * them per database into the handler chain.
72
+ *
73
+ * Mount after `createRequestProtectionMiddleware` (which resolves the bounds
74
+ * this reads) and before the GraphQL handler. Order within the middleware
75
+ * matters: the rate check is O(1) and runs first, so a flood is refused
76
+ * without ever occupying a concurrency slot or waiting in its queue.
77
+ */
78
+ const createAdmissionControlMiddleware = (options = {}) => {
79
+ const concurrency = new express_context_1.ConcurrencyLimiter();
80
+ const rate = new express_context_1.RateWindow(RATE_WINDOW_MS);
81
+ return async (req, res, next) => {
82
+ const protection = protectionOf(req);
83
+ const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
84
+ // ─── Per-caller rate ────────────────────────────────────────────────────
85
+ const hops = resolveHops(req, options.trustedProxyHops);
86
+ const ip = (0, express_context_1.clientIpFrom)(req, hops);
87
+ const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
88
+ if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
89
+ log.warn(`[admission] rate limit: database=${databaseId} ip=${ip} route=${routeOf(req)}`);
90
+ (0, recorder_1.recordRefusal)(req, 'rate_limited', { sourceIp: ip });
91
+ (0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.RATE_LIMITED(), {
92
+ status: 429,
93
+ headers: { 'Retry-After': retryAfterSeconds(rate.retryAfterMs(rateKey)) }
94
+ });
95
+ return;
96
+ }
97
+ // ─── Per-database concurrency ───────────────────────────────────────────
98
+ let lease;
99
+ try {
100
+ lease = await concurrency.acquire(databaseId, {
101
+ limit: protection.maxConcurrentRequests,
102
+ queueWaitMs: protection.maxQueueWaitMs
103
+ });
104
+ }
105
+ catch (e) {
106
+ next(e);
107
+ return;
108
+ }
109
+ if (!lease.granted) {
110
+ log.warn(`[admission] concurrency refused: database=${databaseId} ` +
111
+ `limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
112
+ (0, recorder_1.recordRefusal)(req, lease.refusal === 'queue_timeout' ? 'queue_timeout' : 'concurrency_saturated', {
113
+ sourceIp: ip
114
+ });
115
+ (0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.CONCURRENCY_LIMIT_REACHED({
116
+ limit: protection.maxConcurrentRequests,
117
+ waitedMs: lease.queuedMs
118
+ }), {
119
+ status: 429,
120
+ // The queue is the honest wait estimate: a slot freed sooner than
121
+ // this and the request would already have been admitted.
122
+ headers: { 'Retry-After': retryAfterSeconds(protection.maxQueueWaitMs) }
123
+ });
124
+ return;
125
+ }
126
+ // Release exactly once, whichever ends first. `close` covers the client
127
+ // that hangs up mid-flight — without it an aborted request holds its slot
128
+ // until the process restarts, and a client that retries on abort would
129
+ // drain the tenant's budget one leaked slot at a time. `release` is
130
+ // idempotent, so both listeners can fire.
131
+ res.on('close', () => lease.release());
132
+ res.on('finish', () => lease.release());
133
+ next();
134
+ };
135
+ };
136
+ exports.createAdmissionControlMiddleware = createAdmissionControlMiddleware;
package/middleware/api.js CHANGED
@@ -149,8 +149,8 @@ exports.getSvcKey = getSvcKey;
149
149
  const toApiStructure = (row, opts, settings = {}) => ({
150
150
  apiId: row.api_id,
151
151
  dbname: row.dbname || opts.pg?.database || '',
152
- anonRole: row.anon_role || 'anon',
153
- roleName: row.role_name || 'authenticated',
152
+ anonRole: (0, routing_1.requireApiRole)('anon_role', row.anon_role, row.api_id),
153
+ roleName: (0, routing_1.requireApiRole)('role_name', row.role_name, row.api_id),
154
154
  schema: row.schemas || [],
155
155
  rlsModule: settings.rlsModule,
156
156
  domains: [],
@@ -354,6 +354,11 @@ const createApiMiddleware = (opts) => {
354
354
  res.status(404).send((0, _404_message_1.default)(err.message));
355
355
  return;
356
356
  }
357
+ if (err.code === 'MISSING_API_ROLE') {
358
+ log.error('[api-middleware] resolved API row has no served role:', err.message);
359
+ res.status(500).send(_50x_1.default);
360
+ return;
361
+ }
357
362
  if (err.code === 'NO_DATABASE_ID') {
358
363
  log.error('[api-middleware] no database id resolved:', err.message);
359
364
  res.status(500).send(_50x_1.default);
@@ -5,6 +5,7 @@ require("./types"); // for Request type
5
5
  const errors_1 = require("@constructive-io/errors");
6
6
  const express_context_1 = require("@constructive-io/express-context");
7
7
  const graphql_response_1 = require("../errors/graphql-response");
8
+ const recorder_1 = require("../refusals/recorder");
8
9
  /**
9
10
  * Resolve the bounds this request runs under and attach them to it.
10
11
  *
@@ -54,6 +55,7 @@ const createRequestProtectionMiddleware = () => {
54
55
  if (Number.isFinite(declaredLength) &&
55
56
  declaredLength > protection.maxRequestBytes &&
56
57
  !isMultipart(req)) {
58
+ (0, recorder_1.recordRefusal)(req, 'request_too_large');
57
59
  (0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
58
60
  return;
59
61
  }
@@ -1,5 +1,11 @@
1
1
  import { Pool } from 'pg';
2
2
  import { ApiOptions, ApiStructure } from '../types';
3
+ /**
4
+ * The role an API row names is the role the server will SET ROLE to; a row
5
+ * that leaves one blank is a broken surface, not a request for a default.
6
+ * Throws (code MISSING_API_ROLE) rather than substituting a role.
7
+ */
8
+ export declare const requireApiRole: (column: "role_name" | "anon_role", value: string | null | undefined, apiId: string | undefined) => string;
3
9
  /** Row shape returned by <schema>.resolve_route() — frozen DB↔server contract. */
4
10
  export interface ResolvedRoute {
5
11
  route_binding_id: string | null;
@@ -1,8 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.routeToApiStructure = exports.resolveRoute = exports.isValidSchemaName = exports.getRoutingSchema = exports.DEFAULT_ROUTING_SCHEMA = void 0;
3
+ exports.routeToApiStructure = exports.resolveRoute = exports.isValidSchemaName = exports.getRoutingSchema = exports.DEFAULT_ROUTING_SCHEMA = exports.requireApiRole = void 0;
4
4
  const logger_1 = require("@pgpmjs/logger");
5
5
  const log = new logger_1.Logger('routing');
6
+ // =============================================================================
7
+ // Scoped routing plane (resolve_route contract)
8
+ // =============================================================================
9
+ //
10
+ // One indexed call resolves an incoming request across scopes. The server's
11
+ // projection is HOST-ONLY: Traefik/Ingress owns L7 path/method routing, so
12
+ // the server always calls the frozen contract with the root path and no
13
+ // method:
14
+ //
15
+ // SELECT * FROM <schema>.resolve_route(request_host, '/', NULL)
16
+ //
17
+ // Contract (constructive-db docs/architecture/scoped-domain-routing.md):
18
+ // a single row is always returned; no match → route_binding_id IS NULL.
19
+ /**
20
+ * The role an API row names is the role the server will SET ROLE to; a row
21
+ * that leaves one blank is a broken surface, not a request for a default.
22
+ * Throws (code MISSING_API_ROLE) rather than substituting a role.
23
+ */
24
+ const requireApiRole = (column, value, apiId) => {
25
+ if (typeof value !== 'string' || value.trim() === '') {
26
+ const error = new Error(`API ${apiId ?? '<unknown>'} has no ${column}; a served role is required and there is no default.`);
27
+ error.code = 'MISSING_API_ROLE';
28
+ error.column = column;
29
+ error.apiId = apiId;
30
+ throw error;
31
+ }
32
+ return value;
33
+ };
34
+ exports.requireApiRole = requireApiRole;
6
35
  const RESOLVER_FUNCTION = 'resolve_route';
7
36
  /** Published logical name of the database-scope routing plane. */
8
37
  exports.DEFAULT_ROUTING_SCHEMA = 'routing_public';
@@ -58,13 +87,14 @@ const routeToApiStructure = (route, opts) => {
58
87
  log.debug('[resolve-route] api target missing schemas in resolved_config; no match');
59
88
  return null;
60
89
  }
90
+ const apiId = config.api_id ?? route.target_source_id ?? undefined;
61
91
  return {
62
- apiId: config.api_id ?? route.target_source_id ?? undefined,
92
+ apiId,
63
93
  // Scoped APIs leave dbname NULL when their schemas live in the serving
64
94
  // database; fall back to the server's own database in that case.
65
95
  dbname: config.dbname || opts.pg?.database || '',
66
- anonRole: config.anon_role || 'anon',
67
- roleName: config.role_name || 'authenticated',
96
+ anonRole: (0, exports.requireApiRole)('anon_role', config.anon_role, apiId),
97
+ roleName: (0, exports.requireApiRole)('role_name', config.role_name, apiId),
68
98
  schema: config.schemas,
69
99
  domains: [],
70
100
  databaseId: config.database_id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.20.8",
3
+ "version": "5.21.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.11.2",
47
- "@constructive-io/express-context": "^0.26.2",
48
- "@constructive-io/graphql-env": "^3.31.2",
49
- "@constructive-io/graphql-types": "^3.30.2",
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",
50
50
  "@constructive-io/llm-env": "^0.14.1",
51
- "@constructive-io/query-builder": "^3.13.3",
51
+ "@constructive-io/query-builder": "^3.13.4",
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.2",
55
+ "@pgpmjs/env": "^2.43.3",
56
56
  "@pgpmjs/logger": "^2.25.1",
57
- "@pgpmjs/server-utils": "^3.27.2",
58
- "@pgpmjs/types": "^2.53.0",
57
+ "@pgpmjs/server-utils": "^3.27.3",
58
+ "@pgpmjs/types": "^2.53.1",
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.2",
67
+ "graphile-cache": "^4.12.3",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.3",
70
- "graphile-settings": "^6.21.3",
69
+ "graphile-function-bindings": "^1.14.4",
70
+ "graphile-settings": "^6.21.4",
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.2",
76
+ "pg-cache": "^3.27.3",
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.3",
93
+ "graphile-test": "5.14.4",
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": "4deb5b9a00ead37453857d24274007255092822e"
99
+ "gitHead": "6e2d71a9eb4e1f49f0de0b2b594b2f811de97442"
100
100
  }
@@ -1,5 +1,8 @@
1
1
  import '../middleware/types';
2
+ import type { RefusalReason } from '@constructive-io/express-context';
2
3
  import type { GraphileConfig } from 'graphile-config';
4
+ /** The refusal a gate rejection counts as, or undefined for any other error. */
5
+ export declare const documentRefusalReason: (err: unknown) => RefusalReason | undefined;
3
6
  /**
4
7
  * RequestProtectionPlugin — applies a tenant's document bounds to every
5
8
  * operation.
@@ -1,9 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RequestProtectionPlugin = void 0;
3
+ exports.RequestProtectionPlugin = exports.documentRefusalReason = void 0;
4
4
  require("../middleware/types"); // for Request type
5
5
  const express_context_1 = require("@constructive-io/express-context");
6
+ const grafast_1 = require("grafast");
6
7
  const document_gate_1 = require("../protection/document-gate");
8
+ const recorder_1 = require("../refusals/recorder");
9
+ /** The document-gate error codes, as the refusal taxonomy names them. */
10
+ const DOCUMENT_REFUSALS = {
11
+ QUERY_TOO_DEEP: 'query_too_deep',
12
+ QUERY_TOO_COSTLY: 'query_too_costly',
13
+ PAGE_SIZE_TOO_LARGE: 'page_size_too_large'
14
+ };
15
+ /** The refusal a gate rejection counts as, or undefined for any other error. */
16
+ const documentRefusalReason = (err) => {
17
+ if (!(err instanceof grafast_1.SafeError))
18
+ return undefined;
19
+ const code = err.extensions?.code;
20
+ return typeof code === 'string' ? DOCUMENT_REFUSALS[code] : undefined;
21
+ };
22
+ exports.documentRefusalReason = documentRefusalReason;
7
23
  /**
8
24
  * Get the Express request from a grafserv request context.
9
25
  */
@@ -34,7 +50,17 @@ exports.RequestProtectionPlugin = {
34
50
  // the platform defaults still apply rather than nothing at all.
35
51
  const protection = req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
36
52
  if (args.document && args.schema) {
37
- (0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
53
+ try {
54
+ (0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
55
+ }
56
+ catch (err) {
57
+ // The rejection is still the client's `SafeError`; it is counted on
58
+ // the way out, keyed by the tenant the request resolved.
59
+ const reason = (0, exports.documentRefusalReason)(err);
60
+ if (reason && req)
61
+ (0, recorder_1.recordRefusal)(req, reason);
62
+ throw err;
63
+ }
38
64
  }
39
65
  return next();
40
66
  }
@@ -0,0 +1,78 @@
1
+ import '../middleware/types';
2
+ import type { RefusalReason, RefusalRecorderStats } from '@constructive-io/express-context';
3
+ import { RefusalRecorder } from '@constructive-io/express-context';
4
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
5
+ import type { Request } from 'express';
6
+ import type { Pool } from 'pg';
7
+ /**
8
+ * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
9
+ *
10
+ * One recorder per process, installed by the server at startup and read by
11
+ * every refusal site through `recordRefusal`. Emitters never see the recorder,
12
+ * the pool or a promise: `recordRefusal` is a synchronous counter bump that
13
+ * cannot fail the response being written around it. A harness that mounts a
14
+ * middleware without a server has no recorder installed and the call is a
15
+ * no-op.
16
+ *
17
+ * ## Flush identity
18
+ *
19
+ * A flush runs outside any tenant request, so it establishes its own identity
20
+ * at the top of its transaction: the `platform-bootstrap` service principal
21
+ * (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
22
+ * (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
23
+ * `system` role type (`jwt.claims.role_type`), which the generated writer's
24
+ * guard and RESTRICTIVE insert policy require. Those are the claims every
25
+ * other unattended platform write carries; resolving them is the
26
+ * claim-establishment step at the entry point, not a lookup inside the
27
+ * function being called — `record_refusals` raises if the claims are missing.
28
+ * Resolution is cached after the first success; a failure is reported by the
29
+ * recorder as a failed flush (loud, every interval) and retried next time.
30
+ *
31
+ * @module refusals/recorder
32
+ */
33
+ /** The principal an unattended platform write acts as. */
34
+ export declare const PLATFORM_BOOTSTRAP_PRINCIPAL = "platform-bootstrap";
35
+ /** Make `recorder` the process's recorder. Returns the previous one, if any. */
36
+ export declare const installRefusalRecorder: (recorder: RefusalRecorder | null) => RefusalRecorder | null;
37
+ export declare const getRefusalRecorder: () => RefusalRecorder | null;
38
+ export declare const getRefusalRecorderStats: () => RefusalRecorderStats | null;
39
+ /** The route half of a refusal key — the same shape admission control keys on. */
40
+ export declare const routeKeyOf: (req: Request) => string;
41
+ export interface RecordRefusalOptions {
42
+ /** Overrides the address the refusal is attributed to. */
43
+ sourceIp?: string | null;
44
+ /** See `AdmissionControlOptions.trustedProxyHops`. */
45
+ trustedProxyHops?: number;
46
+ /** Overrides `req.databaseId`. */
47
+ databaseId?: string | null;
48
+ }
49
+ /**
50
+ * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
51
+ * when no recorder is installed.
52
+ */
53
+ export declare const recordRefusal: (req: Request, reason: RefusalReason, opts?: RecordRefusalOptions) => void;
54
+ interface PlatformFlushIdentity {
55
+ databaseId: string;
56
+ actorId: string;
57
+ principalId: string;
58
+ }
59
+ /**
60
+ * Resolve the claims a flush runs under: the platform database and the
61
+ * `platform-bootstrap` service principal's user row. Throws — with the row
62
+ * that was missing named — rather than returning a partial identity.
63
+ */
64
+ export declare const resolvePlatformFlushIdentity: (pool: Pool, principalName?: string) => Promise<PlatformFlushIdentity>;
65
+ export declare const platformFlushClaims: (identity: PlatformFlushIdentity) => Record<string, string>;
66
+ export interface PlatformRefusalRecorderOptions {
67
+ intervalMs?: number;
68
+ jitterMs?: number;
69
+ maxKeys?: number;
70
+ principalName?: string;
71
+ }
72
+ /**
73
+ * The recorder the server runs: counts in memory, flushes into
74
+ * `constructive_usage_private.record_refusals` on the platform pool under the
75
+ * platform flush identity. Not started; the caller owns start/stop.
76
+ */
77
+ export declare const createPlatformRefusalRecorder: (opts: ConstructiveOptions, recorderOpts?: PlatformRefusalRecorderOptions) => RefusalRecorder;
78
+ export {};
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPlatformRefusalRecorder = exports.platformFlushClaims = exports.resolvePlatformFlushIdentity = exports.recordRefusal = exports.routeKeyOf = exports.getRefusalRecorderStats = exports.getRefusalRecorder = exports.installRefusalRecorder = exports.PLATFORM_BOOTSTRAP_PRINCIPAL = void 0;
4
+ require("../middleware/types"); // for Request type
5
+ const express_context_1 = require("@constructive-io/express-context");
6
+ const logger_1 = require("@pgpmjs/logger");
7
+ const pg_cache_1 = require("pg-cache");
8
+ const log = new logger_1.Logger('refusals');
9
+ /**
10
+ * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
11
+ *
12
+ * One recorder per process, installed by the server at startup and read by
13
+ * every refusal site through `recordRefusal`. Emitters never see the recorder,
14
+ * the pool or a promise: `recordRefusal` is a synchronous counter bump that
15
+ * cannot fail the response being written around it. A harness that mounts a
16
+ * middleware without a server has no recorder installed and the call is a
17
+ * no-op.
18
+ *
19
+ * ## Flush identity
20
+ *
21
+ * A flush runs outside any tenant request, so it establishes its own identity
22
+ * at the top of its transaction: the `platform-bootstrap` service principal
23
+ * (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
24
+ * (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
25
+ * `system` role type (`jwt.claims.role_type`), which the generated writer's
26
+ * guard and RESTRICTIVE insert policy require. Those are the claims every
27
+ * other unattended platform write carries; resolving them is the
28
+ * claim-establishment step at the entry point, not a lookup inside the
29
+ * function being called — `record_refusals` raises if the claims are missing.
30
+ * Resolution is cached after the first success; a failure is reported by the
31
+ * recorder as a failed flush (loud, every interval) and retried next time.
32
+ *
33
+ * @module refusals/recorder
34
+ */
35
+ /** The principal an unattended platform write acts as. */
36
+ exports.PLATFORM_BOOTSTRAP_PRINCIPAL = 'platform-bootstrap';
37
+ let installed = null;
38
+ /** Make `recorder` the process's recorder. Returns the previous one, if any. */
39
+ const installRefusalRecorder = (recorder) => {
40
+ const previous = installed;
41
+ installed = recorder;
42
+ return previous;
43
+ };
44
+ exports.installRefusalRecorder = installRefusalRecorder;
45
+ const getRefusalRecorder = () => installed;
46
+ exports.getRefusalRecorder = getRefusalRecorder;
47
+ const getRefusalRecorderStats = () => installed?.stats() ?? null;
48
+ exports.getRefusalRecorderStats = getRefusalRecorderStats;
49
+ /** The route half of a refusal key — the same shape admission control keys on. */
50
+ const routeKeyOf = (req) => `${req.method} ${req.baseUrl ?? ''}${req.path ?? req.url ?? ''}`;
51
+ exports.routeKeyOf = routeKeyOf;
52
+ /**
53
+ * How far back through `X-Forwarded-For` to believe; mirrors admission
54
+ * control's resolution so the refusal source is the same address the limiter
55
+ * keyed on.
56
+ */
57
+ const resolveHops = (req, configured) => {
58
+ if (typeof configured === 'number')
59
+ return configured;
60
+ const fromEnv = (0, express_context_1.trustedProxyHops)();
61
+ if (fromEnv > 0)
62
+ return fromEnv;
63
+ return req.app?.get('trust proxy') ? 1 : 0;
64
+ };
65
+ /**
66
+ * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
67
+ * when no recorder is installed.
68
+ */
69
+ const recordRefusal = (req, reason, opts = {}) => {
70
+ const recorder = installed;
71
+ if (!recorder)
72
+ return;
73
+ const refusal = {
74
+ databaseId: opts.databaseId !== undefined ? opts.databaseId : req.databaseId ?? null,
75
+ lane: 'graphql',
76
+ reason,
77
+ routeKey: (0, exports.routeKeyOf)(req),
78
+ sourceIp: opts.sourceIp !== undefined
79
+ ? opts.sourceIp
80
+ : (0, express_context_1.clientIpFrom)(req, resolveHops(req, opts.trustedProxyHops))
81
+ };
82
+ try {
83
+ recorder.record(refusal);
84
+ }
85
+ catch (err) {
86
+ // The refusal response is already being written; a broken recorder is
87
+ // reported here and via the recorder's own stats, never to the client.
88
+ log.error(`refusal not recorded reason=${reason}: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ };
91
+ exports.recordRefusal = recordRefusal;
92
+ /**
93
+ * Resolve the claims a flush runs under: the platform database and the
94
+ * `platform-bootstrap` service principal's user row. Throws — with the row
95
+ * that was missing named — rather than returning a partial identity.
96
+ */
97
+ const resolvePlatformFlushIdentity = async (pool, principalName = exports.PLATFORM_BOOTSTRAP_PRINCIPAL) => {
98
+ const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
99
+ if (database.rowCount !== 1) {
100
+ throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
101
+ }
102
+ const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
103
+ if (principal.rowCount !== 1) {
104
+ throw new Error(`refusals: no service principal named '${principalName}'`);
105
+ }
106
+ if (principal.rows[0].bypass_step_up !== true) {
107
+ throw new Error(`refusals: principal '${principalName}' is not a service principal (no bypass_step_up)`);
108
+ }
109
+ return {
110
+ databaseId: database.rows[0].id,
111
+ actorId: principal.rows[0].user_id,
112
+ principalId: principal.rows[0].user_id
113
+ };
114
+ };
115
+ exports.resolvePlatformFlushIdentity = resolvePlatformFlushIdentity;
116
+ const platformFlushClaims = (identity) => ({
117
+ 'jwt.claims.database_id': identity.databaseId,
118
+ 'jwt.claims.user_id': identity.actorId,
119
+ 'jwt.claims.principal_id': identity.principalId,
120
+ 'jwt.claims.entity_id': identity.databaseId,
121
+ 'jwt.claims.entity_type': 'database',
122
+ 'jwt.claims.role_type': 'system'
123
+ });
124
+ exports.platformFlushClaims = platformFlushClaims;
125
+ /**
126
+ * The recorder the server runs: counts in memory, flushes into
127
+ * `constructive_usage_private.record_refusals` on the platform pool under the
128
+ * platform flush identity. Not started; the caller owns start/stop.
129
+ */
130
+ const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
131
+ const pool = (0, pg_cache_1.getPgPool)(opts.pg);
132
+ let cached = null;
133
+ const claims = async () => {
134
+ if (cached)
135
+ return cached;
136
+ cached = (0, exports.platformFlushClaims)(await (0, exports.resolvePlatformFlushIdentity)(pool, recorderOpts.principalName));
137
+ log.info(`[refusals] flushing as '${recorderOpts.principalName ?? exports.PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
138
+ return cached;
139
+ };
140
+ return new express_context_1.RefusalRecorder({
141
+ sink: (0, express_context_1.createRecordRefusalsSink)({ pool, claims }),
142
+ intervalMs: recorderOpts.intervalMs,
143
+ jitterMs: recorderOpts.jitterMs,
144
+ maxKeys: recorderOpts.maxKeys
145
+ });
146
+ };
147
+ exports.createPlatformRefusalRecorder = createPlatformRefusalRecorder;
package/server.d.ts CHANGED
@@ -33,6 +33,7 @@ declare class Server {
33
33
  private closed;
34
34
  private httpServer;
35
35
  private debugSampler;
36
+ private refusalRecorder;
36
37
  constructor(opts: ConstructiveOptions);
37
38
  listen(): HttpServer;
38
39
  flush(databaseId: string): Promise<void>;