@constructive-io/graphql-server 4.44.2 → 4.45.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.
@@ -6,6 +6,7 @@ import { createDefaultRegistry, } from '@constructive-io/express-context';
6
6
  import { getPgPool } from 'pg-cache';
7
7
  import errorPage50x from '../errors/50x';
8
8
  import errorPage404Message from '../errors/404-message';
9
+ import { resolveRoute, routeToApiStructure } from './routing';
9
10
  import './types';
10
11
  const log = new Logger('api');
11
12
  // =============================================================================
@@ -255,6 +256,48 @@ const resolveApiNameHeader = async (ctx) => {
255
256
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
256
257
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
257
258
  };
259
+ /**
260
+ * Scoped routing plane resolution (additive, host-only): one indexed
261
+ * resolve_route() call against the compiled hostname/route bindings.
262
+ * Path/method routing belongs to Traefik/Ingress — the server only maps
263
+ * host → tenant/api/db/role. Returns null (fall back to the legacy
264
+ * services_public lookup) when disabled, unmatched, resolver not installed,
265
+ * or the target is not an api surface.
266
+ */
267
+ const resolveScopedRoute = async (ctx) => {
268
+ const { opts, pool, host } = ctx;
269
+ if (!opts.api?.enableScopedRouting)
270
+ return null;
271
+ const schema = opts.api?.scopedRoutingSchema || 'constructive_routing_public';
272
+ const route = await resolveRoute(pool, schema, host);
273
+ if (!route)
274
+ return null;
275
+ const structure = routeToApiStructure(route, opts);
276
+ if (!structure)
277
+ return null;
278
+ log.debug(`[scoped-routing] resolved host=${host} → api=${structure.apiId} db=${structure.dbname}`);
279
+ if (!structure.databaseId || !structure.apiId)
280
+ return structure;
281
+ const loaderCtx = buildLoaderContext(pool, opts, {
282
+ api_id: structure.apiId,
283
+ database_id: structure.databaseId,
284
+ dbname: structure.dbname,
285
+ role_name: structure.roleName,
286
+ anon_role: structure.anonRole,
287
+ is_public: structure.isPublic ?? false,
288
+ schemas: structure.schema,
289
+ });
290
+ const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
291
+ return {
292
+ ...structure,
293
+ rlsModule: settings.rlsModule,
294
+ authSettings: settings.authSettings,
295
+ corsOrigins: settings.corsOrigins,
296
+ databaseSettings: settings.databaseSettings,
297
+ pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
298
+ webauthnSettings: settings.webauthnSettings,
299
+ };
300
+ };
258
301
  const resolveDomainLookup = async (ctx) => {
259
302
  const { opts, pool, domain, subdomain } = ctx;
260
303
  const isPublic = opts.api?.isPublic ?? false;
@@ -333,6 +376,7 @@ export const getApiConfig = async (opts, req) => {
333
376
  subdomain,
334
377
  cacheKey,
335
378
  headers: getRoutingHeaders(req),
379
+ host: req.get('host') || '',
336
380
  };
337
381
  // Validate schemas upfront for modes that need them
338
382
  const apiOpts = opts.api || {};
@@ -365,7 +409,10 @@ export const getApiConfig = async (opts, req) => {
365
409
  result = resolveMetaSchemaHeader(ctx, validatedSchemas);
366
410
  break;
367
411
  case 'domain-lookup':
368
- result = await resolveDomainLookup(ctx);
412
+ result = await resolveScopedRoute(ctx);
413
+ if (!result) {
414
+ result = await resolveDomainLookup(ctx);
415
+ }
369
416
  if (!result && apiOpts.isPublic) {
370
417
  const fallback = await buildDevFallbackError(ctx, req);
371
418
  if (fallback)
@@ -394,6 +441,7 @@ export const createApiMiddleware = (opts) => {
394
441
  subdomain: null,
395
442
  cacheKey: 'meta-api-off',
396
443
  headers: {},
444
+ host: '',
397
445
  });
398
446
  req.databaseId = req.api.databaseId;
399
447
  req.svc_key = 'meta-api-off';
@@ -0,0 +1,63 @@
1
+ import { Logger } from '@pgpmjs/logger';
2
+ const log = new Logger('routing');
3
+ const RESOLVER_FUNCTION = 'resolve_route';
4
+ const isValidSchemaName = (name) => /^[a-z_][a-z0-9_]*$/.test(name);
5
+ /**
6
+ * Resolve a hostname through the compiled scoped-routing plane (host-only:
7
+ * path/method routing belongs to Traefik/Ingress, not the server).
8
+ * Returns null when there is no match (route_binding_id IS NULL) or when the
9
+ * resolver is not installed in the target database — callers fall back to the
10
+ * legacy services_public lookup in both cases.
11
+ */
12
+ export const resolveRoute = async (pool, schema, host) => {
13
+ if (!isValidSchemaName(schema)) {
14
+ log.warn(`[resolve-route] invalid routing schema name: ${schema}`);
15
+ return null;
16
+ }
17
+ try {
18
+ const result = await pool.query(`SELECT * FROM "${schema}".${RESOLVER_FUNCTION}($1, '/', NULL)`, [host]);
19
+ const row = result.rows[0];
20
+ if (!row || row.route_binding_id === null) {
21
+ log.debug(`[resolve-route] no match for host=${host}`);
22
+ return null;
23
+ }
24
+ return row;
25
+ }
26
+ catch (error) {
27
+ const err = error;
28
+ // 42883 undefined_function / 3F000 invalid_schema_name: resolver not
29
+ // installed in this database — treat as no-match so the caller falls back.
30
+ if (err.code === '42883' || err.code === '3F000') {
31
+ log.debug(`[resolve-route] resolver not installed (${err.code}); falling back`);
32
+ return null;
33
+ }
34
+ throw error;
35
+ }
36
+ };
37
+ /**
38
+ * Map a resolved api-target route onto the legacy ApiStructure shape consumed
39
+ * by the rest of the middleware chain. Returns null when the route target is
40
+ * not an api surface or its resolved_config lacks the api essentials — the
41
+ * caller falls back to the legacy lookup.
42
+ */
43
+ export const routeToApiStructure = (route, opts) => {
44
+ if (route.target_module !== 'apis' && route.target_module !== 'api') {
45
+ return null;
46
+ }
47
+ const config = (route.resolved_config ?? {});
48
+ if (!config.dbname || !config.schemas?.length) {
49
+ log.debug('[resolve-route] api target missing dbname/schemas in resolved_config; falling back');
50
+ return null;
51
+ }
52
+ return {
53
+ apiId: config.api_id ?? route.target_source_id ?? undefined,
54
+ dbname: config.dbname || opts.pg?.database || '',
55
+ anonRole: config.anon_role || 'anon',
56
+ roleName: config.role_name || 'authenticated',
57
+ schema: config.schemas,
58
+ apiModules: [],
59
+ domains: [],
60
+ databaseId: config.database_id,
61
+ isPublic: config.is_public ?? (opts.api?.isPublic ?? false),
62
+ };
63
+ };
package/middleware/api.js CHANGED
@@ -12,6 +12,7 @@ const express_context_1 = require("@constructive-io/express-context");
12
12
  const pg_cache_1 = require("pg-cache");
13
13
  const _50x_1 = __importDefault(require("../errors/50x"));
14
14
  const _404_message_1 = __importDefault(require("../errors/404-message"));
15
+ const routing_1 = require("./routing");
15
16
  require("./types");
16
17
  const log = new logger_1.Logger('api');
17
18
  // =============================================================================
@@ -263,6 +264,48 @@ const resolveApiNameHeader = async (ctx) => {
263
264
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
264
265
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
265
266
  };
267
+ /**
268
+ * Scoped routing plane resolution (additive, host-only): one indexed
269
+ * resolve_route() call against the compiled hostname/route bindings.
270
+ * Path/method routing belongs to Traefik/Ingress — the server only maps
271
+ * host → tenant/api/db/role. Returns null (fall back to the legacy
272
+ * services_public lookup) when disabled, unmatched, resolver not installed,
273
+ * or the target is not an api surface.
274
+ */
275
+ const resolveScopedRoute = async (ctx) => {
276
+ const { opts, pool, host } = ctx;
277
+ if (!opts.api?.enableScopedRouting)
278
+ return null;
279
+ const schema = opts.api?.scopedRoutingSchema || 'constructive_routing_public';
280
+ const route = await (0, routing_1.resolveRoute)(pool, schema, host);
281
+ if (!route)
282
+ return null;
283
+ const structure = (0, routing_1.routeToApiStructure)(route, opts);
284
+ if (!structure)
285
+ return null;
286
+ log.debug(`[scoped-routing] resolved host=${host} → api=${structure.apiId} db=${structure.dbname}`);
287
+ if (!structure.databaseId || !structure.apiId)
288
+ return structure;
289
+ const loaderCtx = buildLoaderContext(pool, opts, {
290
+ api_id: structure.apiId,
291
+ database_id: structure.databaseId,
292
+ dbname: structure.dbname,
293
+ role_name: structure.roleName,
294
+ anon_role: structure.anonRole,
295
+ is_public: structure.isPublic ?? false,
296
+ schemas: structure.schema,
297
+ });
298
+ const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
299
+ return {
300
+ ...structure,
301
+ rlsModule: settings.rlsModule,
302
+ authSettings: settings.authSettings,
303
+ corsOrigins: settings.corsOrigins,
304
+ databaseSettings: settings.databaseSettings,
305
+ pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
306
+ webauthnSettings: settings.webauthnSettings,
307
+ };
308
+ };
266
309
  const resolveDomainLookup = async (ctx) => {
267
310
  const { opts, pool, domain, subdomain } = ctx;
268
311
  const isPublic = opts.api?.isPublic ?? false;
@@ -341,6 +384,7 @@ const getApiConfig = async (opts, req) => {
341
384
  subdomain,
342
385
  cacheKey,
343
386
  headers: getRoutingHeaders(req),
387
+ host: req.get('host') || '',
344
388
  };
345
389
  // Validate schemas upfront for modes that need them
346
390
  const apiOpts = opts.api || {};
@@ -373,7 +417,10 @@ const getApiConfig = async (opts, req) => {
373
417
  result = resolveMetaSchemaHeader(ctx, validatedSchemas);
374
418
  break;
375
419
  case 'domain-lookup':
376
- result = await resolveDomainLookup(ctx);
420
+ result = await resolveScopedRoute(ctx);
421
+ if (!result) {
422
+ result = await resolveDomainLookup(ctx);
423
+ }
377
424
  if (!result && apiOpts.isPublic) {
378
425
  const fallback = await buildDevFallbackError(ctx, req);
379
426
  if (fallback)
@@ -403,6 +450,7 @@ const createApiMiddleware = (opts) => {
403
450
  subdomain: null,
404
451
  cacheKey: 'meta-api-off',
405
452
  headers: {},
453
+ host: '',
406
454
  });
407
455
  req.databaseId = req.api.databaseId;
408
456
  req.svc_key = 'meta-api-off';
@@ -0,0 +1,36 @@
1
+ import { Pool } from 'pg';
2
+ import { ApiOptions, ApiStructure } from '../types';
3
+ /** Row shape returned by <schema>.resolve_route() — frozen DB↔server contract. */
4
+ export interface ResolvedRoute {
5
+ route_binding_id: string | null;
6
+ hostname: string | null;
7
+ matched_wildcard: boolean | null;
8
+ matched_path: string | null;
9
+ method: string | null;
10
+ priority: number | null;
11
+ domain_id: string | null;
12
+ target_catalog_id: string | null;
13
+ target_module: string | null;
14
+ target_source_id: string | null;
15
+ target_owner_scope: string | null;
16
+ target_owner_key: string | null;
17
+ resolved_config: Record<string, unknown> | null;
18
+ verification_status: string | null;
19
+ tls_status: string | null;
20
+ tls_secret_name: string | null;
21
+ }
22
+ /**
23
+ * Resolve a hostname through the compiled scoped-routing plane (host-only:
24
+ * path/method routing belongs to Traefik/Ingress, not the server).
25
+ * Returns null when there is no match (route_binding_id IS NULL) or when the
26
+ * resolver is not installed in the target database — callers fall back to the
27
+ * legacy services_public lookup in both cases.
28
+ */
29
+ export declare const resolveRoute: (pool: Pool, schema: string, host: string) => Promise<ResolvedRoute | null>;
30
+ /**
31
+ * Map a resolved api-target route onto the legacy ApiStructure shape consumed
32
+ * by the rest of the middleware chain. Returns null when the route target is
33
+ * not an api surface or its resolved_config lacks the api essentials — the
34
+ * caller falls back to the legacy lookup.
35
+ */
36
+ export declare const routeToApiStructure: (route: ResolvedRoute, opts: ApiOptions) => ApiStructure | null;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.routeToApiStructure = exports.resolveRoute = void 0;
4
+ const logger_1 = require("@pgpmjs/logger");
5
+ const log = new logger_1.Logger('routing');
6
+ const RESOLVER_FUNCTION = 'resolve_route';
7
+ const isValidSchemaName = (name) => /^[a-z_][a-z0-9_]*$/.test(name);
8
+ /**
9
+ * Resolve a hostname through the compiled scoped-routing plane (host-only:
10
+ * path/method routing belongs to Traefik/Ingress, not the server).
11
+ * Returns null when there is no match (route_binding_id IS NULL) or when the
12
+ * resolver is not installed in the target database — callers fall back to the
13
+ * legacy services_public lookup in both cases.
14
+ */
15
+ const resolveRoute = async (pool, schema, host) => {
16
+ if (!isValidSchemaName(schema)) {
17
+ log.warn(`[resolve-route] invalid routing schema name: ${schema}`);
18
+ return null;
19
+ }
20
+ try {
21
+ const result = await pool.query(`SELECT * FROM "${schema}".${RESOLVER_FUNCTION}($1, '/', NULL)`, [host]);
22
+ const row = result.rows[0];
23
+ if (!row || row.route_binding_id === null) {
24
+ log.debug(`[resolve-route] no match for host=${host}`);
25
+ return null;
26
+ }
27
+ return row;
28
+ }
29
+ catch (error) {
30
+ const err = error;
31
+ // 42883 undefined_function / 3F000 invalid_schema_name: resolver not
32
+ // installed in this database — treat as no-match so the caller falls back.
33
+ if (err.code === '42883' || err.code === '3F000') {
34
+ log.debug(`[resolve-route] resolver not installed (${err.code}); falling back`);
35
+ return null;
36
+ }
37
+ throw error;
38
+ }
39
+ };
40
+ exports.resolveRoute = resolveRoute;
41
+ /**
42
+ * Map a resolved api-target route onto the legacy ApiStructure shape consumed
43
+ * by the rest of the middleware chain. Returns null when the route target is
44
+ * not an api surface or its resolved_config lacks the api essentials — the
45
+ * caller falls back to the legacy lookup.
46
+ */
47
+ const routeToApiStructure = (route, opts) => {
48
+ if (route.target_module !== 'apis' && route.target_module !== 'api') {
49
+ return null;
50
+ }
51
+ const config = (route.resolved_config ?? {});
52
+ if (!config.dbname || !config.schemas?.length) {
53
+ log.debug('[resolve-route] api target missing dbname/schemas in resolved_config; falling back');
54
+ return null;
55
+ }
56
+ return {
57
+ apiId: config.api_id ?? route.target_source_id ?? undefined,
58
+ dbname: config.dbname || opts.pg?.database || '',
59
+ anonRole: config.anon_role || 'anon',
60
+ roleName: config.role_name || 'authenticated',
61
+ schema: config.schemas,
62
+ apiModules: [],
63
+ domains: [],
64
+ databaseId: config.database_id,
65
+ isPublic: config.is_public ?? (opts.api?.isPublic ?? false),
66
+ };
67
+ };
68
+ exports.routeToApiStructure = routeToApiStructure;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.44.2",
3
+ "version": "4.45.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -42,18 +42,18 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "@constructive-io/csrf": "^0.17.0",
45
- "@constructive-io/express-context": "^0.12.1",
46
- "@constructive-io/graphql-env": "^3.16.1",
47
- "@constructive-io/graphql-types": "^3.14.1",
45
+ "@constructive-io/express-context": "^0.12.2",
46
+ "@constructive-io/graphql-env": "^3.16.3",
47
+ "@constructive-io/graphql-types": "^3.15.0",
48
48
  "@constructive-io/query-builder": "^2.25.0",
49
49
  "@constructive-io/s3-utils": "^2.20.0",
50
50
  "@constructive-io/url-domains": "^2.19.0",
51
51
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
52
- "@pgpmjs/env": "^2.28.1",
52
+ "@pgpmjs/env": "^2.28.2",
53
53
  "@pgpmjs/logger": "^2.14.0",
54
- "@pgpmjs/server-utils": "^3.15.2",
55
- "@pgpmjs/types": "^2.35.0",
56
- "agentic-server": "0.11.1",
54
+ "@pgpmjs/server-utils": "^3.15.3",
55
+ "@pgpmjs/types": "^2.35.1",
56
+ "agentic-server": "0.11.2",
57
57
  "cors": "^2.8.6",
58
58
  "deepmerge": "^4.3.1",
59
59
  "express": "^5.2.1",
@@ -62,17 +62,17 @@
62
62
  "grafserv": "1.0.0",
63
63
  "graphile-build": "5.0.2",
64
64
  "graphile-build-pg": "5.0.2",
65
- "graphile-cache": "^3.15.1",
65
+ "graphile-cache": "^3.15.2",
66
66
  "graphile-config": "1.0.1",
67
- "graphile-function-bindings": "^0.5.1",
68
- "graphile-settings": "^5.15.1",
67
+ "graphile-function-bindings": "^0.5.3",
68
+ "graphile-settings": "^5.17.0",
69
69
  "graphile-utils": "5.0.1",
70
70
  "graphql": "16.13.0",
71
71
  "graphql-upload": "^13.0.0",
72
72
  "lru-cache": "^11.2.7",
73
73
  "pg": "^8.21.0",
74
- "pg-cache": "^3.15.1",
75
- "pg-env": "^1.18.0",
74
+ "pg-cache": "^3.15.2",
75
+ "pg-env": "^1.18.1",
76
76
  "pg-query-context": "^2.19.0",
77
77
  "pg-sql2": "5.0.1",
78
78
  "postgraphile": "5.0.3",
@@ -87,10 +87,10 @@
87
87
  "@types/pg": "^8.20.0",
88
88
  "@types/request-ip": "^0.0.41",
89
89
  "cookie-parser": "^1.4.7",
90
- "graphile-test": "4.21.1",
90
+ "graphile-test": "4.21.3",
91
91
  "makage": "^0.3.0",
92
92
  "nodemon": "^3.1.14",
93
93
  "ts-node": "^10.9.2"
94
94
  },
95
- "gitHead": "0e4acd5d0db04587fb0112b047be6f2c8f2493d9"
95
+ "gitHead": "b0843e23070cf4e28a119b06e4ebd1828be1ebde"
96
96
  }