@constructive-io/graphql-server 4.44.3 → 4.45.1
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/esm/middleware/api.js +49 -1
- package/esm/middleware/routing.js +63 -0
- package/middleware/api.js +49 -1
- package/middleware/routing.d.ts +36 -0
- package/middleware/routing.js +68 -0
- package/package.json +8 -8
package/esm/middleware/api.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.45.1",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@constructive-io/csrf": "^0.17.0",
|
|
45
45
|
"@constructive-io/express-context": "^0.12.2",
|
|
46
|
-
"@constructive-io/graphql-env": "^3.16.
|
|
47
|
-
"@constructive-io/graphql-types": "^3.
|
|
48
|
-
"@constructive-io/query-builder": "^2.25.
|
|
46
|
+
"@constructive-io/graphql-env": "^3.16.3",
|
|
47
|
+
"@constructive-io/graphql-types": "^3.15.0",
|
|
48
|
+
"@constructive-io/query-builder": "^2.25.1",
|
|
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",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"graphile-build-pg": "5.0.2",
|
|
65
65
|
"graphile-cache": "^3.15.2",
|
|
66
66
|
"graphile-config": "1.0.1",
|
|
67
|
-
"graphile-function-bindings": "^0.5.
|
|
68
|
-
"graphile-settings": "^5.
|
|
67
|
+
"graphile-function-bindings": "^0.5.4",
|
|
68
|
+
"graphile-settings": "^5.17.1",
|
|
69
69
|
"graphile-utils": "5.0.1",
|
|
70
70
|
"graphql": "16.13.0",
|
|
71
71
|
"graphql-upload": "^13.0.0",
|
|
@@ -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.
|
|
90
|
+
"graphile-test": "4.21.4",
|
|
91
91
|
"makage": "^0.3.0",
|
|
92
92
|
"nodemon": "^3.1.14",
|
|
93
93
|
"ts-node": "^10.9.2"
|
|
94
94
|
},
|
|
95
|
-
"gitHead": "
|
|
95
|
+
"gitHead": "dbec64be89f2d62d3c77f0026a95c2d477de052a"
|
|
96
96
|
}
|