@constructive-io/graphql-server 5.0.4 → 5.1.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/esm/middleware/api.js +49 -157
- package/esm/middleware/flush.js +4 -10
- package/esm/middleware/graphile.js +24 -23
- package/esm/middleware/routing.js +9 -10
- package/esm/server.js +16 -15
- package/middleware/api.d.ts +1 -1
- package/middleware/api.js +49 -157
- package/middleware/flush.d.ts +1 -1
- package/middleware/flush.js +4 -10
- package/middleware/graphile.d.ts +1 -1
- package/middleware/graphile.js +24 -23
- package/middleware/routing.d.ts +5 -6
- package/middleware/routing.js +9 -10
- package/package.json +14 -14
- package/server.js +15 -14
package/esm/middleware/api.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import
|
|
1
|
+
import './types';
|
|
2
|
+
import { createDefaultRegistry } from '@constructive-io/express-context';
|
|
3
|
+
import { parseUrl } from '@constructive-io/url-domains';
|
|
2
4
|
import { Logger } from '@pgpmjs/logger';
|
|
3
5
|
import { svcCache } from '@pgpmjs/server-utils';
|
|
4
|
-
import { parseUrl } from '@constructive-io/url-domains';
|
|
5
|
-
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
9
|
import { resolveRoute, routeToApiStructure } from './routing';
|
|
10
|
-
import './types';
|
|
11
10
|
const log = new Logger('api');
|
|
12
11
|
// =============================================================================
|
|
13
12
|
// Module Loader Registry (replaces inline SQL queries for per-db config)
|
|
@@ -16,87 +15,49 @@ const defaultRegistry = createDefaultRegistry();
|
|
|
16
15
|
// =============================================================================
|
|
17
16
|
// SQL Queries (API resolution only — module queries now live in loaders)
|
|
18
17
|
// =============================================================================
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
a.database_id,
|
|
23
|
-
a.dbname,
|
|
24
|
-
a.role_name,
|
|
25
|
-
a.anon_role,
|
|
26
|
-
a.is_public,
|
|
27
|
-
COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
|
|
28
|
-
FROM services_public.domains d
|
|
29
|
-
JOIN services_public.apis a ON d.api_id = a.id
|
|
30
|
-
LEFT JOIN services_public.api_schemas aps ON a.id = aps.api_id
|
|
31
|
-
LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
|
|
32
|
-
WHERE d.domain = $1
|
|
33
|
-
AND (($2::text IS NULL AND d.subdomain IS NULL) OR d.subdomain = $2)
|
|
34
|
-
AND a.is_public = $3
|
|
35
|
-
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
36
|
-
LIMIT 1
|
|
37
|
-
`;
|
|
38
|
-
const API_NAME_LOOKUP_SQL = `
|
|
18
|
+
// Private-header X-Api-Name lookup against the scoped routing plane.
|
|
19
|
+
// `is_published` is the routing-plane analog of the legacy `is_public` column.
|
|
20
|
+
const SCOPED_API_NAME_LOOKUP_SQL = `
|
|
39
21
|
SELECT
|
|
40
22
|
a.id as api_id,
|
|
41
23
|
a.database_id,
|
|
42
24
|
a.dbname,
|
|
43
25
|
a.role_name,
|
|
44
26
|
a.anon_role,
|
|
45
|
-
a.is_public,
|
|
27
|
+
a.is_published as is_public,
|
|
46
28
|
COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
|
|
47
|
-
FROM
|
|
48
|
-
LEFT JOIN
|
|
29
|
+
FROM constructive_routing_public.apis a
|
|
30
|
+
LEFT JOIN constructive_routing_public.api_schemas aps ON a.id = aps.api_id
|
|
49
31
|
LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
|
|
50
32
|
WHERE a.database_id = $1
|
|
51
33
|
AND a.name = $2
|
|
52
|
-
AND a.
|
|
53
|
-
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.
|
|
34
|
+
AND a.is_published = $3
|
|
35
|
+
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_published
|
|
54
36
|
LIMIT 1
|
|
55
37
|
`;
|
|
56
|
-
const API_LIST_SQL = `
|
|
57
|
-
SELECT
|
|
58
|
-
a.id,
|
|
59
|
-
a.database_id,
|
|
60
|
-
a.name,
|
|
61
|
-
a.dbname,
|
|
62
|
-
a.role_name,
|
|
63
|
-
a.anon_role,
|
|
64
|
-
a.is_public,
|
|
65
|
-
COALESCE(
|
|
66
|
-
json_agg(
|
|
67
|
-
json_build_object('domain', d.domain, 'subdomain', d.subdomain)
|
|
68
|
-
) FILTER (WHERE d.domain IS NOT NULL),
|
|
69
|
-
'[]'
|
|
70
|
-
) as domains
|
|
71
|
-
FROM services_public.apis a
|
|
72
|
-
LEFT JOIN services_public.domains d ON a.id = d.api_id
|
|
73
|
-
WHERE a.is_public = $1
|
|
74
|
-
GROUP BY a.id, a.database_id, a.name, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
75
|
-
LIMIT 100
|
|
76
|
-
`;
|
|
77
38
|
/**
|
|
78
39
|
* Build a LoaderContext from the API row and options.
|
|
79
40
|
* This is used to resolve per-database module settings via the loader registry.
|
|
80
41
|
*/
|
|
81
|
-
const buildLoaderContext = (
|
|
82
|
-
|
|
42
|
+
const buildLoaderContext = (routingPool, opts, row) => ({
|
|
43
|
+
routingPool,
|
|
83
44
|
tenantPool: getPgPool({ ...opts.pg, database: row.dbname }),
|
|
84
45
|
databaseId: row.database_id,
|
|
85
46
|
apiId: row.api_id,
|
|
86
|
-
dbname: row.dbname
|
|
47
|
+
dbname: row.dbname
|
|
87
48
|
});
|
|
88
49
|
/**
|
|
89
50
|
* Resolve all per-database module settings in parallel via the loader registry.
|
|
90
51
|
* Each loader independently caches by databaseId — repeated calls are cheap.
|
|
91
52
|
*/
|
|
92
53
|
const resolveModuleSettings = async (registry, ctx) => {
|
|
93
|
-
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings
|
|
54
|
+
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
|
|
94
55
|
registry.resolve('rlsModule', ctx),
|
|
95
56
|
registry.resolve('authSettings', ctx),
|
|
96
57
|
registry.resolve('corsOrigins', ctx),
|
|
97
58
|
registry.resolve('databaseSettings', ctx),
|
|
98
59
|
registry.resolve('pubkeyChallengeSettings', ctx),
|
|
99
|
-
registry.resolve('webauthnSettings', ctx)
|
|
60
|
+
registry.resolve('webauthnSettings', ctx)
|
|
100
61
|
]);
|
|
101
62
|
return {
|
|
102
63
|
rlsModule,
|
|
@@ -104,7 +65,7 @@ const resolveModuleSettings = async (registry, ctx) => {
|
|
|
104
65
|
corsOrigins,
|
|
105
66
|
databaseSettings,
|
|
106
67
|
pubkeyChallengeSettings,
|
|
107
|
-
webauthnSettings
|
|
68
|
+
webauthnSettings
|
|
108
69
|
};
|
|
109
70
|
};
|
|
110
71
|
// =============================================================================
|
|
@@ -125,14 +86,14 @@ const getRoutingHeaders = (req) => ({
|
|
|
125
86
|
schemata: req.get('X-Schemata'),
|
|
126
87
|
apiName: req.get('X-Api-Name'),
|
|
127
88
|
metaSchema: req.get('X-Meta-Schema'),
|
|
128
|
-
databaseId: req.get('X-Database-Id')
|
|
89
|
+
databaseId: req.get('X-Database-Id')
|
|
129
90
|
});
|
|
130
91
|
const getUrlDomains = (req) => {
|
|
131
92
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
132
93
|
const parsed = parseUrl(fullUrl);
|
|
133
94
|
return {
|
|
134
95
|
domain: parsed.domain ?? '',
|
|
135
|
-
subdomains: parsed.subdomains ?? []
|
|
96
|
+
subdomains: parsed.subdomains ?? []
|
|
136
97
|
};
|
|
137
98
|
};
|
|
138
99
|
export const getSubdomain = (subdomains) => {
|
|
@@ -172,7 +133,7 @@ const toApiStructure = (row, opts, settings = {}) => ({
|
|
|
172
133
|
corsOrigins: settings.corsOrigins,
|
|
173
134
|
databaseSettings: settings.databaseSettings,
|
|
174
135
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
175
|
-
webauthnSettings: settings.webauthnSettings
|
|
136
|
+
webauthnSettings: settings.webauthnSettings
|
|
176
137
|
});
|
|
177
138
|
const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
178
139
|
dbname: opts.pg?.database ?? '',
|
|
@@ -182,7 +143,7 @@ const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
|
182
143
|
apiModules: [],
|
|
183
144
|
domains: [],
|
|
184
145
|
databaseId,
|
|
185
|
-
isPublic: false
|
|
146
|
+
isPublic: false
|
|
186
147
|
});
|
|
187
148
|
// =============================================================================
|
|
188
149
|
// Database Queries (API resolution only)
|
|
@@ -191,31 +152,25 @@ const validateSchemata = async (pool, schemas) => {
|
|
|
191
152
|
const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
|
|
192
153
|
return result.rows.map((row) => row.schema_name);
|
|
193
154
|
};
|
|
194
|
-
const queryByDomain = async (pool, domain, subdomain, isPublic) => {
|
|
195
|
-
const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]);
|
|
196
|
-
return result.rows[0] ?? null;
|
|
197
|
-
};
|
|
198
155
|
const queryByApiName = async (pool, databaseId, name, isPublic) => {
|
|
199
|
-
const result = await pool.query(
|
|
156
|
+
const result = await pool.query(SCOPED_API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]);
|
|
200
157
|
return result.rows[0] ?? null;
|
|
201
158
|
};
|
|
202
|
-
const queryApiList = async (pool, isPublic) => {
|
|
203
|
-
const result = await pool.query(API_LIST_SQL, [isPublic]);
|
|
204
|
-
return result.rows;
|
|
205
|
-
};
|
|
206
159
|
// =============================================================================
|
|
207
160
|
// Resolution Logic
|
|
208
161
|
// =============================================================================
|
|
209
162
|
const determineMode = (ctx) => {
|
|
210
163
|
const { opts, headers } = ctx;
|
|
211
|
-
|
|
212
|
-
|
|
164
|
+
// Static single-tenant mode: scoped routing off — expose configured schemas
|
|
165
|
+
// directly with no route resolution.
|
|
166
|
+
if (!opts.api?.enableScopedRouting)
|
|
167
|
+
return 'static';
|
|
213
168
|
if (opts.api?.isPublic === false) {
|
|
214
|
-
return getPrivateHeaderMode(headers) ?? '
|
|
169
|
+
return getPrivateHeaderMode(headers) ?? 'scoped-route';
|
|
215
170
|
}
|
|
216
|
-
return '
|
|
171
|
+
return 'scoped-route';
|
|
217
172
|
};
|
|
218
|
-
const
|
|
173
|
+
const resolveStatic = (ctx) => {
|
|
219
174
|
const { opts } = ctx;
|
|
220
175
|
return {
|
|
221
176
|
dbname: opts.pg?.database ?? '',
|
|
@@ -225,7 +180,7 @@ const resolveServicesDisabled = (ctx) => {
|
|
|
225
180
|
apiModules: [],
|
|
226
181
|
domains: [],
|
|
227
182
|
databaseId: opts.api?.defaultDatabaseId,
|
|
228
|
-
isPublic: false
|
|
183
|
+
isPublic: false
|
|
229
184
|
};
|
|
230
185
|
};
|
|
231
186
|
const resolveSchemataHeader = async (ctx, validatedSchemas) => {
|
|
@@ -257,12 +212,12 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
|
257
212
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
258
213
|
};
|
|
259
214
|
/**
|
|
260
|
-
* Scoped routing plane resolution (
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* host
|
|
264
|
-
*
|
|
265
|
-
*
|
|
215
|
+
* Scoped routing plane resolution (host-only): one indexed resolve_route()
|
|
216
|
+
* call against the compiled hostname/route bindings. Path/method routing
|
|
217
|
+
* belongs to Traefik/Ingress — the server only maps host → tenant/api/db/role.
|
|
218
|
+
* This is the sole host resolver. Returns null (→ 404) when disabled,
|
|
219
|
+
* unmatched, the resolver is not installed, or the target is not an api
|
|
220
|
+
* surface. There is no legacy fallback.
|
|
266
221
|
*/
|
|
267
222
|
const resolveScopedRoute = async (ctx) => {
|
|
268
223
|
const { opts, pool, host } = ctx;
|
|
@@ -285,7 +240,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
285
240
|
role_name: structure.roleName,
|
|
286
241
|
anon_role: structure.anonRole,
|
|
287
242
|
is_public: structure.isPublic ?? false,
|
|
288
|
-
schemas: structure.schema
|
|
243
|
+
schemas: structure.schema
|
|
289
244
|
});
|
|
290
245
|
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
291
246
|
return {
|
|
@@ -295,63 +250,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
295
250
|
corsOrigins: settings.corsOrigins,
|
|
296
251
|
databaseSettings: settings.databaseSettings,
|
|
297
252
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
298
|
-
webauthnSettings: settings.webauthnSettings
|
|
299
|
-
};
|
|
300
|
-
};
|
|
301
|
-
const resolveDomainLookup = async (ctx) => {
|
|
302
|
-
const { opts, pool, domain, subdomain } = ctx;
|
|
303
|
-
const isPublic = opts.api?.isPublic ?? false;
|
|
304
|
-
log.debug(`[domain-lookup] domain=${domain} subdomain=${subdomain} isPublic=${isPublic}`);
|
|
305
|
-
const row = await queryByDomain(pool, domain, subdomain, isPublic);
|
|
306
|
-
if (!row) {
|
|
307
|
-
log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
311
|
-
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
312
|
-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
313
|
-
return toApiStructure(row, opts, settings);
|
|
314
|
-
};
|
|
315
|
-
const buildDevFallbackError = async (ctx, req) => {
|
|
316
|
-
if (getNodeEnv() !== 'development')
|
|
317
|
-
return null;
|
|
318
|
-
const isPublic = ctx.opts.api?.isPublic ?? false;
|
|
319
|
-
const apis = await queryApiList(ctx.pool, isPublic);
|
|
320
|
-
if (!apis.length)
|
|
321
|
-
return null;
|
|
322
|
-
const host = req.get('host') || '';
|
|
323
|
-
const portMatch = host.match(/:(\d+)$/);
|
|
324
|
-
const port = portMatch ? portMatch[1] : '';
|
|
325
|
-
const apiCards = apis.map((api) => {
|
|
326
|
-
const domains = api.domains.length
|
|
327
|
-
? api.domains.map((d) => {
|
|
328
|
-
const hostname = d.subdomain ? `${d.subdomain}.${d.domain}` : d.domain;
|
|
329
|
-
const url = port ? `http://${hostname}:${port}/graphiql` : `http://${hostname}/graphiql`;
|
|
330
|
-
return `<a href="${url}" style="color:#01A1FF;text-decoration:none;font-weight:500" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">${hostname}</a>`;
|
|
331
|
-
}).join('<span style="color:#D4DCEA;margin:0 4px">·</span>')
|
|
332
|
-
: '<span style="color:#8E9398;font-style:italic;font-size:11px">no domains</span>';
|
|
333
|
-
const badge = api.is_public
|
|
334
|
-
? '<span style="color:#01A1FF;font-size:10px;font-weight:500">public</span>'
|
|
335
|
-
: '<span style="color:#8E9398;font-size:10px">private</span>';
|
|
336
|
-
return `
|
|
337
|
-
<div style="background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:6px;box-shadow:0 1px 3px rgba(0,0,0,0.04);border:1px solid #E8ECF0;display:flex;align-items:center;gap:12px;transition:background 0.15s" onmouseover="this.style.background='#FAFBFC'" onmouseout="this.style.background='#fff'">
|
|
338
|
-
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:8px;font-size:13px">
|
|
339
|
-
<span style="font-weight:600;color:#232323;white-space:nowrap">${api.name}</span>
|
|
340
|
-
<span style="color:#D4DCEA">→</span>
|
|
341
|
-
${domains}
|
|
342
|
-
</div>
|
|
343
|
-
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
344
|
-
<span style="color:#8E9398;font-size:11px;font-family:'SF Mono',Monaco,monospace">${api.dbname}</span>
|
|
345
|
-
${badge}
|
|
346
|
-
</div>
|
|
347
|
-
</div>`;
|
|
348
|
-
}).join('');
|
|
349
|
-
return {
|
|
350
|
-
errorHtml: `
|
|
351
|
-
<div style="text-align:left;max-width:600px;margin:0 auto">
|
|
352
|
-
<p style="color:#8E9398;font-size:11px;margin-bottom:10px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px">Available APIs</p>
|
|
353
|
-
${apiCards}
|
|
354
|
-
</div>`,
|
|
253
|
+
webauthnSettings: settings.webauthnSettings
|
|
355
254
|
};
|
|
356
255
|
};
|
|
357
256
|
// =============================================================================
|
|
@@ -376,7 +275,7 @@ export const getApiConfig = async (opts, req) => {
|
|
|
376
275
|
subdomain,
|
|
377
276
|
cacheKey,
|
|
378
277
|
headers: getRoutingHeaders(req),
|
|
379
|
-
host: req.get('host') || ''
|
|
278
|
+
host: req.get('host') || ''
|
|
380
279
|
};
|
|
381
280
|
// Validate schemas upfront for modes that need them
|
|
382
281
|
const apiOpts = opts.api || {};
|
|
@@ -396,8 +295,8 @@ export const getApiConfig = async (opts, req) => {
|
|
|
396
295
|
const mode = determineMode(ctx);
|
|
397
296
|
let result;
|
|
398
297
|
switch (mode) {
|
|
399
|
-
case '
|
|
400
|
-
result =
|
|
298
|
+
case 'static':
|
|
299
|
+
result = resolveStatic(ctx);
|
|
401
300
|
break;
|
|
402
301
|
case 'schemata-header':
|
|
403
302
|
result = await resolveSchemataHeader(ctx, validatedSchemas);
|
|
@@ -408,16 +307,8 @@ export const getApiConfig = async (opts, req) => {
|
|
|
408
307
|
case 'meta-schema-header':
|
|
409
308
|
result = resolveMetaSchemaHeader(ctx, validatedSchemas);
|
|
410
309
|
break;
|
|
411
|
-
case '
|
|
310
|
+
case 'scoped-route':
|
|
412
311
|
result = await resolveScopedRoute(ctx);
|
|
413
|
-
if (!result) {
|
|
414
|
-
result = await resolveDomainLookup(ctx);
|
|
415
|
-
}
|
|
416
|
-
if (!result && apiOpts.isPublic) {
|
|
417
|
-
const fallback = await buildDevFallbackError(ctx, req);
|
|
418
|
-
if (fallback)
|
|
419
|
-
return fallback;
|
|
420
|
-
}
|
|
421
312
|
break;
|
|
422
313
|
}
|
|
423
314
|
// Cache successful results
|
|
@@ -432,19 +323,20 @@ export const getApiConfig = async (opts, req) => {
|
|
|
432
323
|
export const createApiMiddleware = (opts) => {
|
|
433
324
|
return async (req, res, next) => {
|
|
434
325
|
log.debug(`[api-middleware] ${req.method} ${req.path}`);
|
|
435
|
-
// Fast path:
|
|
436
|
-
|
|
437
|
-
|
|
326
|
+
// Fast path: static single-tenant mode (scoped routing disabled) — no
|
|
327
|
+
// route resolution, expose the configured schemas directly.
|
|
328
|
+
if (!opts.api?.enableScopedRouting) {
|
|
329
|
+
req.api = resolveStatic({
|
|
438
330
|
opts,
|
|
439
331
|
pool: null,
|
|
440
332
|
domain: '',
|
|
441
333
|
subdomain: null,
|
|
442
|
-
cacheKey: '
|
|
334
|
+
cacheKey: 'static',
|
|
443
335
|
headers: {},
|
|
444
|
-
host: ''
|
|
336
|
+
host: ''
|
|
445
337
|
});
|
|
446
338
|
req.databaseId = req.api.databaseId;
|
|
447
|
-
req.svc_key = '
|
|
339
|
+
req.svc_key = 'static';
|
|
448
340
|
return next();
|
|
449
341
|
}
|
|
450
342
|
try {
|
package/esm/middleware/flush.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import './types'; // for Request type
|
|
1
2
|
import { Logger } from '@pgpmjs/logger';
|
|
2
3
|
import { svcCache } from '@pgpmjs/server-utils';
|
|
3
4
|
import { graphileCache } from 'graphile-cache';
|
|
4
5
|
import { getPgPool } from 'pg-cache';
|
|
5
|
-
import './types'; // for Request type
|
|
6
6
|
const log = new Logger('flush');
|
|
7
7
|
export const flush = async (req, res, next) => {
|
|
8
8
|
if (req.url === '/flush') {
|
|
@@ -28,19 +28,13 @@ export const flushService = async (opts, databaseId) => {
|
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
const svc = await pgPool.query(`SELECT
|
|
32
|
-
FROM
|
|
31
|
+
const svc = await pgPool.query(`SELECT hostname
|
|
32
|
+
FROM constructive_routing_public.domains
|
|
33
33
|
WHERE database_id = $1`, [databaseId]);
|
|
34
34
|
if (svc.rowCount === 0)
|
|
35
35
|
return;
|
|
36
36
|
for (const row of svc.rows) {
|
|
37
|
-
|
|
38
|
-
if (row.domain && !row.subdomain) {
|
|
39
|
-
key = row.domain;
|
|
40
|
-
}
|
|
41
|
-
else if (row.domain && row.subdomain) {
|
|
42
|
-
key = `${row.subdomain}.${row.domain}`;
|
|
43
|
-
}
|
|
37
|
+
const key = row.hostname || undefined;
|
|
44
38
|
if (key) {
|
|
45
39
|
graphileCache.delete(key);
|
|
46
40
|
svcCache.delete(key);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import './types'; // for Request type
|
|
1
2
|
import crypto from 'node:crypto';
|
|
2
3
|
import { getNodeEnv } from '@pgpmjs/env';
|
|
3
4
|
import { Logger } from '@pgpmjs/logger';
|
|
@@ -6,11 +7,10 @@ import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
|
6
7
|
import { createConstructivePreset, makePgService } from 'graphile-settings';
|
|
7
8
|
import { getPgPool } from 'pg-cache';
|
|
8
9
|
import { getPgEnvOptions } from 'pg-env';
|
|
9
|
-
import './types'; // for Request type
|
|
10
10
|
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
11
11
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
12
|
-
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
13
12
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
13
|
+
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
14
14
|
const maskErrorLog = new Logger('graphile:maskError');
|
|
15
15
|
const SAFE_ERROR_CODES = new Set([
|
|
16
16
|
// GraphQL standard
|
|
@@ -115,7 +115,7 @@ const SAFE_ERROR_CODES = new Set([
|
|
|
115
115
|
'23503', // foreign_key_violation
|
|
116
116
|
'23502', // not_null_violation
|
|
117
117
|
'23514', // check_violation
|
|
118
|
-
'23P01'
|
|
118
|
+
'23P01' // exclusion_violation
|
|
119
119
|
]);
|
|
120
120
|
/**
|
|
121
121
|
* Production-aware error masking function.
|
|
@@ -142,8 +142,8 @@ const maskError = (error) => {
|
|
|
142
142
|
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
143
143
|
extensions: {
|
|
144
144
|
code: 'INTERNAL_SERVER_ERROR',
|
|
145
|
-
errorId
|
|
146
|
-
}
|
|
145
|
+
errorId
|
|
146
|
+
}
|
|
147
147
|
};
|
|
148
148
|
};
|
|
149
149
|
// =============================================================================
|
|
@@ -204,24 +204,24 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
204
204
|
definitionsTable: m.definitionsTableName,
|
|
205
205
|
invocationsSchema: m.invocationsSchemaName,
|
|
206
206
|
invocationsTable: m.invocationsTableName,
|
|
207
|
-
invocationsEntityField: m.invocationsEntityField
|
|
208
|
-
}))
|
|
209
|
-
})
|
|
207
|
+
invocationsEntityField: m.invocationsEntityField
|
|
208
|
+
}))
|
|
209
|
+
})
|
|
210
210
|
]
|
|
211
|
-
: [])
|
|
211
|
+
: [])
|
|
212
212
|
],
|
|
213
213
|
pgServices: [
|
|
214
214
|
makePgService({
|
|
215
215
|
pool,
|
|
216
|
-
schemas
|
|
217
|
-
})
|
|
216
|
+
schemas
|
|
217
|
+
})
|
|
218
218
|
],
|
|
219
219
|
grafserv: {
|
|
220
220
|
graphqlPath: '/graphql',
|
|
221
221
|
graphiqlPath: '/graphiql',
|
|
222
222
|
graphiql: true,
|
|
223
223
|
graphiqlOnGraphQLGET: false,
|
|
224
|
-
maskError
|
|
224
|
+
maskError
|
|
225
225
|
},
|
|
226
226
|
grafast: {
|
|
227
227
|
explain: process.env.NODE_ENV === 'development',
|
|
@@ -234,8 +234,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
234
234
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
235
235
|
}
|
|
236
236
|
// API provenance — which API surface this request arrived through.
|
|
237
|
-
// Derived server-side
|
|
238
|
-
//
|
|
237
|
+
// Derived server-side by resolving the hostname through the scoped
|
|
238
|
+
// routing plane (resolve_route -> api_id); never taken from
|
|
239
|
+
// client-supplied headers, body, or token payload.
|
|
239
240
|
if (req.api?.apiId) {
|
|
240
241
|
context['jwt.claims.api_id'] = req.api.apiId;
|
|
241
242
|
}
|
|
@@ -256,7 +257,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
256
257
|
role: roleName,
|
|
257
258
|
'jwt.claims.token_id': req.token.id,
|
|
258
259
|
'jwt.claims.user_id': req.token.user_id,
|
|
259
|
-
...context
|
|
260
|
+
...context
|
|
260
261
|
};
|
|
261
262
|
if (req.token.session_id) {
|
|
262
263
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
@@ -283,16 +284,16 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
283
284
|
}
|
|
284
285
|
const anonSettings = {
|
|
285
286
|
role: anonRole,
|
|
286
|
-
...context
|
|
287
|
+
...context
|
|
287
288
|
};
|
|
288
289
|
if (req?.requestId) {
|
|
289
290
|
anonSettings['request.id'] = req.requestId;
|
|
290
291
|
}
|
|
291
292
|
return {
|
|
292
|
-
pgSettings: anonSettings
|
|
293
|
+
pgSettings: anonSettings
|
|
293
294
|
};
|
|
294
|
-
}
|
|
295
|
-
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
296
297
|
};
|
|
297
298
|
};
|
|
298
299
|
export const graphile = (opts) => {
|
|
@@ -355,7 +356,7 @@ export const graphile = (opts) => {
|
|
|
355
356
|
log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
|
|
356
357
|
const pgConfig = getPgEnvOptions({
|
|
357
358
|
...opts.pg,
|
|
358
|
-
database: dbname
|
|
359
|
+
database: dbname
|
|
359
360
|
});
|
|
360
361
|
// Route through pg-cache so the pool is tracked and can be cleaned up
|
|
361
362
|
// properly, preventing leaked connections during database teardown.
|
|
@@ -366,11 +367,11 @@ export const graphile = (opts) => {
|
|
|
366
367
|
const creationPromise = observeGraphileBuild({
|
|
367
368
|
cacheKey: key,
|
|
368
369
|
serviceKey: key,
|
|
369
|
-
databaseId: api.databaseId ?? null
|
|
370
|
+
databaseId: api.databaseId ?? null
|
|
370
371
|
}, () => createGraphileInstance({
|
|
371
372
|
preset,
|
|
372
373
|
cacheKey: key,
|
|
373
|
-
enableRealtime: api.databaseSettings?.enableRealtime
|
|
374
|
+
enableRealtime: api.databaseSettings?.enableRealtime
|
|
374
375
|
}), { enabled: observabilityEnabled });
|
|
375
376
|
creating.set(key, creationPromise);
|
|
376
377
|
try {
|
|
@@ -383,7 +384,7 @@ export const graphile = (opts) => {
|
|
|
383
384
|
log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
|
|
384
385
|
throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
385
386
|
cacheKey: key,
|
|
386
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
387
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
387
388
|
});
|
|
388
389
|
}
|
|
389
390
|
finally {
|
|
@@ -6,8 +6,8 @@ const isValidSchemaName = (name) => /^[a-z_][a-z0-9_]*$/.test(name);
|
|
|
6
6
|
* Resolve a hostname through the compiled scoped-routing plane (host-only:
|
|
7
7
|
* path/method routing belongs to Traefik/Ingress, not the server).
|
|
8
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 —
|
|
10
|
-
*
|
|
9
|
+
* resolver is not installed in the target database — in both cases the caller
|
|
10
|
+
* treats it as a hard no-match (→ 404); there is no legacy fallback.
|
|
11
11
|
*/
|
|
12
12
|
export const resolveRoute = async (pool, schema, host) => {
|
|
13
13
|
if (!isValidSchemaName(schema)) {
|
|
@@ -26,19 +26,18 @@ export const resolveRoute = async (pool, schema, host) => {
|
|
|
26
26
|
catch (error) {
|
|
27
27
|
const err = error;
|
|
28
28
|
// 42883 undefined_function / 3F000 invalid_schema_name: resolver not
|
|
29
|
-
// installed in this database — treat as no-match
|
|
29
|
+
// installed in this database — treat as a hard no-match.
|
|
30
30
|
if (err.code === '42883' || err.code === '3F000') {
|
|
31
|
-
log.debug(`[resolve-route] resolver not installed (${err.code});
|
|
31
|
+
log.debug(`[resolve-route] resolver not installed (${err.code}); no match`);
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
throw error;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
|
-
* Map a resolved api-target route onto the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* caller falls back to the legacy lookup.
|
|
38
|
+
* Map a resolved api-target route onto the ApiStructure shape consumed by the
|
|
39
|
+
* rest of the middleware chain. Returns null when the route target is not an
|
|
40
|
+
* api surface or its resolved_config lacks the api essentials (→ 404).
|
|
42
41
|
*/
|
|
43
42
|
export const routeToApiStructure = (route, opts) => {
|
|
44
43
|
if (route.target_module !== 'apis' && route.target_module !== 'api') {
|
|
@@ -46,7 +45,7 @@ export const routeToApiStructure = (route, opts) => {
|
|
|
46
45
|
}
|
|
47
46
|
const config = (route.resolved_config ?? {});
|
|
48
47
|
if (!config.dbname || !config.schemas?.length) {
|
|
49
|
-
log.debug('[resolve-route] api target missing dbname/schemas in resolved_config;
|
|
48
|
+
log.debug('[resolve-route] api target missing dbname/schemas in resolved_config; no match');
|
|
50
49
|
return null;
|
|
51
50
|
}
|
|
52
51
|
return {
|
|
@@ -58,6 +57,6 @@ export const routeToApiStructure = (route, opts) => {
|
|
|
58
57
|
apiModules: [],
|
|
59
58
|
domains: [],
|
|
60
59
|
databaseId: config.database_id,
|
|
61
|
-
isPublic: config.is_public ?? (opts.api?.isPublic ?? false)
|
|
60
|
+
isPublic: config.is_public ?? (opts.api?.isPublic ?? false)
|
|
62
61
|
};
|
|
63
62
|
};
|