@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/server.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
import { createCsrfMiddleware } from '@constructive-io/csrf';
|
|
2
|
+
import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context';
|
|
2
3
|
import { getEnvOptions } from '@constructive-io/graphql-env';
|
|
4
|
+
import { middleware as parseDomains } from '@constructive-io/url-domains';
|
|
3
5
|
import { Logger } from '@pgpmjs/logger';
|
|
4
6
|
import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
|
|
5
|
-
import {
|
|
7
|
+
import { createAgenticRouter } from 'agentic-server';
|
|
6
8
|
import cookieParser from 'cookie-parser';
|
|
7
9
|
import express from 'express';
|
|
10
|
+
import { closeAllCaches, graphileCache } from 'graphile-cache';
|
|
8
11
|
import graphqlUpload from 'graphql-upload';
|
|
9
|
-
import { graphileCache, closeAllCaches } from 'graphile-cache';
|
|
10
12
|
import { getPgPool } from 'pg-cache';
|
|
11
13
|
import requestIp from 'request-ip';
|
|
12
14
|
import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
|
|
13
|
-
import {
|
|
15
|
+
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
16
|
+
import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability';
|
|
14
17
|
import { createApiMiddleware } from './middleware/api';
|
|
15
18
|
import { createAuthenticateMiddleware } from './middleware/auth';
|
|
19
|
+
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
20
|
+
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
21
|
+
import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
16
22
|
import { cors } from './middleware/cors';
|
|
17
23
|
import { errorHandler, notFoundHandler } from './middleware/error-handler';
|
|
18
24
|
import { favicon } from './middleware/favicon';
|
|
19
|
-
import { createFnRouter } from './middleware/fn';
|
|
20
25
|
import { flush, flushService } from './middleware/flush';
|
|
26
|
+
import { createFnRouter } from './middleware/fn';
|
|
21
27
|
import { graphile } from './middleware/graphile';
|
|
22
28
|
import { multipartBridge } from './middleware/multipart-bridge';
|
|
23
29
|
import { createDebugDatabaseMiddleware } from './middleware/observability/debug-db';
|
|
24
30
|
import { debugMemory } from './middleware/observability/debug-memory';
|
|
25
31
|
import { localObservabilityOnly } from './middleware/observability/guard';
|
|
26
32
|
import { createRequestLogger } from './middleware/observability/request-logger';
|
|
27
|
-
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
28
|
-
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
29
|
-
import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
30
|
-
import { createAgenticRouter } from 'agentic-server';
|
|
31
|
-
import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context';
|
|
32
|
-
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
33
33
|
const log = new Logger('server');
|
|
34
34
|
/**
|
|
35
35
|
* Creates and starts a GraphQL server instance
|
|
@@ -85,12 +85,13 @@ class Server {
|
|
|
85
85
|
serverHost: effectiveOpts.server?.host,
|
|
86
86
|
serverPort: effectiveOpts.server?.port,
|
|
87
87
|
apiIsPublic: apiOpts.isPublic,
|
|
88
|
-
|
|
88
|
+
enableScopedRouting: apiOpts.enableScopedRouting,
|
|
89
|
+
scopedRoutingSchema: apiOpts.scopedRoutingSchema,
|
|
89
90
|
metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
|
|
90
91
|
exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
|
|
91
92
|
anonRole: apiOpts.anonRole,
|
|
92
93
|
roleName: apiOpts.roleName,
|
|
93
|
-
observabilityEnabled
|
|
94
|
+
observabilityEnabled
|
|
94
95
|
});
|
|
95
96
|
if (observabilityRequested && !observabilityEnabled) {
|
|
96
97
|
const reasons = [];
|
|
@@ -129,7 +130,7 @@ class Server {
|
|
|
129
130
|
app.use(cors(fallbackOrigin));
|
|
130
131
|
app.use('/graphql', graphqlUpload.graphqlUploadExpress({
|
|
131
132
|
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
132
|
-
maxFiles: 10
|
|
133
|
+
maxFiles: 10
|
|
133
134
|
}));
|
|
134
135
|
// Rewrite Content-Type after graphql-upload so grafserv accepts the request
|
|
135
136
|
app.use('/graphql', multipartBridge);
|
|
@@ -147,8 +148,8 @@ class Server {
|
|
|
147
148
|
cookieOptions: {
|
|
148
149
|
httpOnly: false, // SPA clients need to read this via document.cookie
|
|
149
150
|
secure: process.env.NODE_ENV === 'production',
|
|
150
|
-
sameSite: 'lax'
|
|
151
|
-
}
|
|
151
|
+
sameSite: 'lax'
|
|
152
|
+
}
|
|
152
153
|
});
|
|
153
154
|
const csrfProtect = (req, res, next) => {
|
|
154
155
|
// Skip CSRF for Bearer token auth
|
package/middleware/api.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import './types';
|
|
1
2
|
import { NextFunction, Request, Response } from 'express';
|
|
2
3
|
import { ApiConfigResult, ApiOptions } from '../types';
|
|
3
|
-
import './types';
|
|
4
4
|
export declare const getSubdomain: (subdomains: string[]) => string | null;
|
|
5
5
|
export declare const getSvcKey: (opts: ApiOptions, req: Request) => string;
|
|
6
6
|
export declare const getApiConfig: (opts: ApiOptions, req: Request) => Promise<ApiConfigResult>;
|
package/middleware/api.js
CHANGED
|
@@ -4,16 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createApiMiddleware = exports.getApiConfig = exports.getSvcKey = exports.getSubdomain = void 0;
|
|
7
|
-
|
|
7
|
+
require("./types");
|
|
8
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
9
|
+
const url_domains_1 = require("@constructive-io/url-domains");
|
|
8
10
|
const logger_1 = require("@pgpmjs/logger");
|
|
9
11
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
10
|
-
const url_domains_1 = require("@constructive-io/url-domains");
|
|
11
|
-
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
15
|
const routing_1 = require("./routing");
|
|
16
|
-
require("./types");
|
|
17
16
|
const log = new logger_1.Logger('api');
|
|
18
17
|
// =============================================================================
|
|
19
18
|
// Module Loader Registry (replaces inline SQL queries for per-db config)
|
|
@@ -22,87 +21,49 @@ const defaultRegistry = (0, express_context_1.createDefaultRegistry)();
|
|
|
22
21
|
// =============================================================================
|
|
23
22
|
// SQL Queries (API resolution only — module queries now live in loaders)
|
|
24
23
|
// =============================================================================
|
|
25
|
-
|
|
24
|
+
// Private-header X-Api-Name lookup against the scoped routing plane.
|
|
25
|
+
// `is_published` is the routing-plane analog of the legacy `is_public` column.
|
|
26
|
+
const SCOPED_API_NAME_LOOKUP_SQL = `
|
|
26
27
|
SELECT
|
|
27
28
|
a.id as api_id,
|
|
28
29
|
a.database_id,
|
|
29
30
|
a.dbname,
|
|
30
31
|
a.role_name,
|
|
31
32
|
a.anon_role,
|
|
32
|
-
a.is_public,
|
|
33
|
+
a.is_published as is_public,
|
|
33
34
|
COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
|
|
34
|
-
FROM
|
|
35
|
-
JOIN
|
|
36
|
-
LEFT JOIN services_public.api_schemas aps ON a.id = aps.api_id
|
|
37
|
-
LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
|
|
38
|
-
WHERE d.domain = $1
|
|
39
|
-
AND (($2::text IS NULL AND d.subdomain IS NULL) OR d.subdomain = $2)
|
|
40
|
-
AND a.is_public = $3
|
|
41
|
-
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
42
|
-
LIMIT 1
|
|
43
|
-
`;
|
|
44
|
-
const API_NAME_LOOKUP_SQL = `
|
|
45
|
-
SELECT
|
|
46
|
-
a.id as api_id,
|
|
47
|
-
a.database_id,
|
|
48
|
-
a.dbname,
|
|
49
|
-
a.role_name,
|
|
50
|
-
a.anon_role,
|
|
51
|
-
a.is_public,
|
|
52
|
-
COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
|
|
53
|
-
FROM services_public.apis a
|
|
54
|
-
LEFT JOIN services_public.api_schemas aps ON a.id = aps.api_id
|
|
35
|
+
FROM constructive_routing_public.apis a
|
|
36
|
+
LEFT JOIN constructive_routing_public.api_schemas aps ON a.id = aps.api_id
|
|
55
37
|
LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
|
|
56
38
|
WHERE a.database_id = $1
|
|
57
39
|
AND a.name = $2
|
|
58
|
-
AND a.
|
|
59
|
-
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.
|
|
40
|
+
AND a.is_published = $3
|
|
41
|
+
GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_published
|
|
60
42
|
LIMIT 1
|
|
61
43
|
`;
|
|
62
|
-
const API_LIST_SQL = `
|
|
63
|
-
SELECT
|
|
64
|
-
a.id,
|
|
65
|
-
a.database_id,
|
|
66
|
-
a.name,
|
|
67
|
-
a.dbname,
|
|
68
|
-
a.role_name,
|
|
69
|
-
a.anon_role,
|
|
70
|
-
a.is_public,
|
|
71
|
-
COALESCE(
|
|
72
|
-
json_agg(
|
|
73
|
-
json_build_object('domain', d.domain, 'subdomain', d.subdomain)
|
|
74
|
-
) FILTER (WHERE d.domain IS NOT NULL),
|
|
75
|
-
'[]'
|
|
76
|
-
) as domains
|
|
77
|
-
FROM services_public.apis a
|
|
78
|
-
LEFT JOIN services_public.domains d ON a.id = d.api_id
|
|
79
|
-
WHERE a.is_public = $1
|
|
80
|
-
GROUP BY a.id, a.database_id, a.name, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
81
|
-
LIMIT 100
|
|
82
|
-
`;
|
|
83
44
|
/**
|
|
84
45
|
* Build a LoaderContext from the API row and options.
|
|
85
46
|
* This is used to resolve per-database module settings via the loader registry.
|
|
86
47
|
*/
|
|
87
|
-
const buildLoaderContext = (
|
|
88
|
-
|
|
48
|
+
const buildLoaderContext = (routingPool, opts, row) => ({
|
|
49
|
+
routingPool,
|
|
89
50
|
tenantPool: (0, pg_cache_1.getPgPool)({ ...opts.pg, database: row.dbname }),
|
|
90
51
|
databaseId: row.database_id,
|
|
91
52
|
apiId: row.api_id,
|
|
92
|
-
dbname: row.dbname
|
|
53
|
+
dbname: row.dbname
|
|
93
54
|
});
|
|
94
55
|
/**
|
|
95
56
|
* Resolve all per-database module settings in parallel via the loader registry.
|
|
96
57
|
* Each loader independently caches by databaseId — repeated calls are cheap.
|
|
97
58
|
*/
|
|
98
59
|
const resolveModuleSettings = async (registry, ctx) => {
|
|
99
|
-
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings
|
|
60
|
+
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
|
|
100
61
|
registry.resolve('rlsModule', ctx),
|
|
101
62
|
registry.resolve('authSettings', ctx),
|
|
102
63
|
registry.resolve('corsOrigins', ctx),
|
|
103
64
|
registry.resolve('databaseSettings', ctx),
|
|
104
65
|
registry.resolve('pubkeyChallengeSettings', ctx),
|
|
105
|
-
registry.resolve('webauthnSettings', ctx)
|
|
66
|
+
registry.resolve('webauthnSettings', ctx)
|
|
106
67
|
]);
|
|
107
68
|
return {
|
|
108
69
|
rlsModule,
|
|
@@ -110,7 +71,7 @@ const resolveModuleSettings = async (registry, ctx) => {
|
|
|
110
71
|
corsOrigins,
|
|
111
72
|
databaseSettings,
|
|
112
73
|
pubkeyChallengeSettings,
|
|
113
|
-
webauthnSettings
|
|
74
|
+
webauthnSettings
|
|
114
75
|
};
|
|
115
76
|
};
|
|
116
77
|
// =============================================================================
|
|
@@ -131,14 +92,14 @@ const getRoutingHeaders = (req) => ({
|
|
|
131
92
|
schemata: req.get('X-Schemata'),
|
|
132
93
|
apiName: req.get('X-Api-Name'),
|
|
133
94
|
metaSchema: req.get('X-Meta-Schema'),
|
|
134
|
-
databaseId: req.get('X-Database-Id')
|
|
95
|
+
databaseId: req.get('X-Database-Id')
|
|
135
96
|
});
|
|
136
97
|
const getUrlDomains = (req) => {
|
|
137
98
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
138
99
|
const parsed = (0, url_domains_1.parseUrl)(fullUrl);
|
|
139
100
|
return {
|
|
140
101
|
domain: parsed.domain ?? '',
|
|
141
|
-
subdomains: parsed.subdomains ?? []
|
|
102
|
+
subdomains: parsed.subdomains ?? []
|
|
142
103
|
};
|
|
143
104
|
};
|
|
144
105
|
const getSubdomain = (subdomains) => {
|
|
@@ -180,7 +141,7 @@ const toApiStructure = (row, opts, settings = {}) => ({
|
|
|
180
141
|
corsOrigins: settings.corsOrigins,
|
|
181
142
|
databaseSettings: settings.databaseSettings,
|
|
182
143
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
183
|
-
webauthnSettings: settings.webauthnSettings
|
|
144
|
+
webauthnSettings: settings.webauthnSettings
|
|
184
145
|
});
|
|
185
146
|
const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
186
147
|
dbname: opts.pg?.database ?? '',
|
|
@@ -190,7 +151,7 @@ const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
|
190
151
|
apiModules: [],
|
|
191
152
|
domains: [],
|
|
192
153
|
databaseId,
|
|
193
|
-
isPublic: false
|
|
154
|
+
isPublic: false
|
|
194
155
|
});
|
|
195
156
|
// =============================================================================
|
|
196
157
|
// Database Queries (API resolution only)
|
|
@@ -199,31 +160,25 @@ const validateSchemata = async (pool, schemas) => {
|
|
|
199
160
|
const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
|
|
200
161
|
return result.rows.map((row) => row.schema_name);
|
|
201
162
|
};
|
|
202
|
-
const queryByDomain = async (pool, domain, subdomain, isPublic) => {
|
|
203
|
-
const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]);
|
|
204
|
-
return result.rows[0] ?? null;
|
|
205
|
-
};
|
|
206
163
|
const queryByApiName = async (pool, databaseId, name, isPublic) => {
|
|
207
|
-
const result = await pool.query(
|
|
164
|
+
const result = await pool.query(SCOPED_API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]);
|
|
208
165
|
return result.rows[0] ?? null;
|
|
209
166
|
};
|
|
210
|
-
const queryApiList = async (pool, isPublic) => {
|
|
211
|
-
const result = await pool.query(API_LIST_SQL, [isPublic]);
|
|
212
|
-
return result.rows;
|
|
213
|
-
};
|
|
214
167
|
// =============================================================================
|
|
215
168
|
// Resolution Logic
|
|
216
169
|
// =============================================================================
|
|
217
170
|
const determineMode = (ctx) => {
|
|
218
171
|
const { opts, headers } = ctx;
|
|
219
|
-
|
|
220
|
-
|
|
172
|
+
// Static single-tenant mode: scoped routing off — expose configured schemas
|
|
173
|
+
// directly with no route resolution.
|
|
174
|
+
if (!opts.api?.enableScopedRouting)
|
|
175
|
+
return 'static';
|
|
221
176
|
if (opts.api?.isPublic === false) {
|
|
222
|
-
return getPrivateHeaderMode(headers) ?? '
|
|
177
|
+
return getPrivateHeaderMode(headers) ?? 'scoped-route';
|
|
223
178
|
}
|
|
224
|
-
return '
|
|
179
|
+
return 'scoped-route';
|
|
225
180
|
};
|
|
226
|
-
const
|
|
181
|
+
const resolveStatic = (ctx) => {
|
|
227
182
|
const { opts } = ctx;
|
|
228
183
|
return {
|
|
229
184
|
dbname: opts.pg?.database ?? '',
|
|
@@ -233,7 +188,7 @@ const resolveServicesDisabled = (ctx) => {
|
|
|
233
188
|
apiModules: [],
|
|
234
189
|
domains: [],
|
|
235
190
|
databaseId: opts.api?.defaultDatabaseId,
|
|
236
|
-
isPublic: false
|
|
191
|
+
isPublic: false
|
|
237
192
|
};
|
|
238
193
|
};
|
|
239
194
|
const resolveSchemataHeader = async (ctx, validatedSchemas) => {
|
|
@@ -265,12 +220,12 @@ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
|
265
220
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
266
221
|
};
|
|
267
222
|
/**
|
|
268
|
-
* Scoped routing plane resolution (
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* host
|
|
272
|
-
*
|
|
273
|
-
*
|
|
223
|
+
* Scoped routing plane resolution (host-only): one indexed resolve_route()
|
|
224
|
+
* call against the compiled hostname/route bindings. Path/method routing
|
|
225
|
+
* belongs to Traefik/Ingress — the server only maps host → tenant/api/db/role.
|
|
226
|
+
* This is the sole host resolver. Returns null (→ 404) when disabled,
|
|
227
|
+
* unmatched, the resolver is not installed, or the target is not an api
|
|
228
|
+
* surface. There is no legacy fallback.
|
|
274
229
|
*/
|
|
275
230
|
const resolveScopedRoute = async (ctx) => {
|
|
276
231
|
const { opts, pool, host } = ctx;
|
|
@@ -293,7 +248,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
293
248
|
role_name: structure.roleName,
|
|
294
249
|
anon_role: structure.anonRole,
|
|
295
250
|
is_public: structure.isPublic ?? false,
|
|
296
|
-
schemas: structure.schema
|
|
251
|
+
schemas: structure.schema
|
|
297
252
|
});
|
|
298
253
|
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
299
254
|
return {
|
|
@@ -303,63 +258,7 @@ const resolveScopedRoute = async (ctx) => {
|
|
|
303
258
|
corsOrigins: settings.corsOrigins,
|
|
304
259
|
databaseSettings: settings.databaseSettings,
|
|
305
260
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
306
|
-
webauthnSettings: settings.webauthnSettings
|
|
307
|
-
};
|
|
308
|
-
};
|
|
309
|
-
const resolveDomainLookup = async (ctx) => {
|
|
310
|
-
const { opts, pool, domain, subdomain } = ctx;
|
|
311
|
-
const isPublic = opts.api?.isPublic ?? false;
|
|
312
|
-
log.debug(`[domain-lookup] domain=${domain} subdomain=${subdomain} isPublic=${isPublic}`);
|
|
313
|
-
const row = await queryByDomain(pool, domain, subdomain, isPublic);
|
|
314
|
-
if (!row) {
|
|
315
|
-
log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
319
|
-
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
320
|
-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
321
|
-
return toApiStructure(row, opts, settings);
|
|
322
|
-
};
|
|
323
|
-
const buildDevFallbackError = async (ctx, req) => {
|
|
324
|
-
if ((0, env_1.getNodeEnv)() !== 'development')
|
|
325
|
-
return null;
|
|
326
|
-
const isPublic = ctx.opts.api?.isPublic ?? false;
|
|
327
|
-
const apis = await queryApiList(ctx.pool, isPublic);
|
|
328
|
-
if (!apis.length)
|
|
329
|
-
return null;
|
|
330
|
-
const host = req.get('host') || '';
|
|
331
|
-
const portMatch = host.match(/:(\d+)$/);
|
|
332
|
-
const port = portMatch ? portMatch[1] : '';
|
|
333
|
-
const apiCards = apis.map((api) => {
|
|
334
|
-
const domains = api.domains.length
|
|
335
|
-
? api.domains.map((d) => {
|
|
336
|
-
const hostname = d.subdomain ? `${d.subdomain}.${d.domain}` : d.domain;
|
|
337
|
-
const url = port ? `http://${hostname}:${port}/graphiql` : `http://${hostname}/graphiql`;
|
|
338
|
-
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>`;
|
|
339
|
-
}).join('<span style="color:#D4DCEA;margin:0 4px">·</span>')
|
|
340
|
-
: '<span style="color:#8E9398;font-style:italic;font-size:11px">no domains</span>';
|
|
341
|
-
const badge = api.is_public
|
|
342
|
-
? '<span style="color:#01A1FF;font-size:10px;font-weight:500">public</span>'
|
|
343
|
-
: '<span style="color:#8E9398;font-size:10px">private</span>';
|
|
344
|
-
return `
|
|
345
|
-
<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'">
|
|
346
|
-
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:8px;font-size:13px">
|
|
347
|
-
<span style="font-weight:600;color:#232323;white-space:nowrap">${api.name}</span>
|
|
348
|
-
<span style="color:#D4DCEA">→</span>
|
|
349
|
-
${domains}
|
|
350
|
-
</div>
|
|
351
|
-
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
352
|
-
<span style="color:#8E9398;font-size:11px;font-family:'SF Mono',Monaco,monospace">${api.dbname}</span>
|
|
353
|
-
${badge}
|
|
354
|
-
</div>
|
|
355
|
-
</div>`;
|
|
356
|
-
}).join('');
|
|
357
|
-
return {
|
|
358
|
-
errorHtml: `
|
|
359
|
-
<div style="text-align:left;max-width:600px;margin:0 auto">
|
|
360
|
-
<p style="color:#8E9398;font-size:11px;margin-bottom:10px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px">Available APIs</p>
|
|
361
|
-
${apiCards}
|
|
362
|
-
</div>`,
|
|
261
|
+
webauthnSettings: settings.webauthnSettings
|
|
363
262
|
};
|
|
364
263
|
};
|
|
365
264
|
// =============================================================================
|
|
@@ -384,7 +283,7 @@ const getApiConfig = async (opts, req) => {
|
|
|
384
283
|
subdomain,
|
|
385
284
|
cacheKey,
|
|
386
285
|
headers: getRoutingHeaders(req),
|
|
387
|
-
host: req.get('host') || ''
|
|
286
|
+
host: req.get('host') || ''
|
|
388
287
|
};
|
|
389
288
|
// Validate schemas upfront for modes that need them
|
|
390
289
|
const apiOpts = opts.api || {};
|
|
@@ -404,8 +303,8 @@ const getApiConfig = async (opts, req) => {
|
|
|
404
303
|
const mode = determineMode(ctx);
|
|
405
304
|
let result;
|
|
406
305
|
switch (mode) {
|
|
407
|
-
case '
|
|
408
|
-
result =
|
|
306
|
+
case 'static':
|
|
307
|
+
result = resolveStatic(ctx);
|
|
409
308
|
break;
|
|
410
309
|
case 'schemata-header':
|
|
411
310
|
result = await resolveSchemataHeader(ctx, validatedSchemas);
|
|
@@ -416,16 +315,8 @@ const getApiConfig = async (opts, req) => {
|
|
|
416
315
|
case 'meta-schema-header':
|
|
417
316
|
result = resolveMetaSchemaHeader(ctx, validatedSchemas);
|
|
418
317
|
break;
|
|
419
|
-
case '
|
|
318
|
+
case 'scoped-route':
|
|
420
319
|
result = await resolveScopedRoute(ctx);
|
|
421
|
-
if (!result) {
|
|
422
|
-
result = await resolveDomainLookup(ctx);
|
|
423
|
-
}
|
|
424
|
-
if (!result && apiOpts.isPublic) {
|
|
425
|
-
const fallback = await buildDevFallbackError(ctx, req);
|
|
426
|
-
if (fallback)
|
|
427
|
-
return fallback;
|
|
428
|
-
}
|
|
429
320
|
break;
|
|
430
321
|
}
|
|
431
322
|
// Cache successful results
|
|
@@ -441,19 +332,20 @@ exports.getApiConfig = getApiConfig;
|
|
|
441
332
|
const createApiMiddleware = (opts) => {
|
|
442
333
|
return async (req, res, next) => {
|
|
443
334
|
log.debug(`[api-middleware] ${req.method} ${req.path}`);
|
|
444
|
-
// Fast path:
|
|
445
|
-
|
|
446
|
-
|
|
335
|
+
// Fast path: static single-tenant mode (scoped routing disabled) — no
|
|
336
|
+
// route resolution, expose the configured schemas directly.
|
|
337
|
+
if (!opts.api?.enableScopedRouting) {
|
|
338
|
+
req.api = resolveStatic({
|
|
447
339
|
opts,
|
|
448
340
|
pool: null,
|
|
449
341
|
domain: '',
|
|
450
342
|
subdomain: null,
|
|
451
|
-
cacheKey: '
|
|
343
|
+
cacheKey: 'static',
|
|
452
344
|
headers: {},
|
|
453
|
-
host: ''
|
|
345
|
+
host: ''
|
|
454
346
|
});
|
|
455
347
|
req.databaseId = req.api.databaseId;
|
|
456
|
-
req.svc_key = '
|
|
348
|
+
req.svc_key = 'static';
|
|
457
349
|
return next();
|
|
458
350
|
}
|
|
459
351
|
try {
|
package/middleware/flush.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import './types';
|
|
1
2
|
import { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
2
3
|
import { NextFunction, Request, Response } from 'express';
|
|
3
|
-
import './types';
|
|
4
4
|
export declare const flush: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
5
5
|
export declare const flushService: (opts: ConstructiveOptions, databaseId: string) => Promise<void>;
|
package/middleware/flush.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.flushService = exports.flush = void 0;
|
|
4
|
+
require("./types"); // for Request type
|
|
4
5
|
const logger_1 = require("@pgpmjs/logger");
|
|
5
6
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
6
7
|
const graphile_cache_1 = require("graphile-cache");
|
|
7
8
|
const pg_cache_1 = require("pg-cache");
|
|
8
|
-
require("./types"); // for Request type
|
|
9
9
|
const log = new logger_1.Logger('flush');
|
|
10
10
|
const flush = async (req, res, next) => {
|
|
11
11
|
if (req.url === '/flush') {
|
|
@@ -32,19 +32,13 @@ const flushService = async (opts, databaseId) => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
-
const svc = await pgPool.query(`SELECT
|
|
36
|
-
FROM
|
|
35
|
+
const svc = await pgPool.query(`SELECT hostname
|
|
36
|
+
FROM constructive_routing_public.domains
|
|
37
37
|
WHERE database_id = $1`, [databaseId]);
|
|
38
38
|
if (svc.rowCount === 0)
|
|
39
39
|
return;
|
|
40
40
|
for (const row of svc.rows) {
|
|
41
|
-
|
|
42
|
-
if (row.domain && !row.subdomain) {
|
|
43
|
-
key = row.domain;
|
|
44
|
-
}
|
|
45
|
-
else if (row.domain && row.subdomain) {
|
|
46
|
-
key = `${row.subdomain}.${row.domain}`;
|
|
47
|
-
}
|
|
41
|
+
const key = row.hostname || undefined;
|
|
48
42
|
if (key) {
|
|
49
43
|
graphile_cache_1.graphileCache.delete(key);
|
|
50
44
|
server_utils_1.svcCache.delete(key);
|
package/middleware/graphile.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import './types';
|
|
1
2
|
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
2
3
|
import type { RequestHandler } from 'express';
|
|
3
|
-
import './types';
|
|
4
4
|
/**
|
|
5
5
|
* Returns the number of currently in-flight handler creation operations.
|
|
6
6
|
* Useful for monitoring and debugging.
|
package/middleware/graphile.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.graphile = void 0;
|
|
|
7
7
|
exports.getInFlightCount = getInFlightCount;
|
|
8
8
|
exports.getInFlightKeys = getInFlightKeys;
|
|
9
9
|
exports.clearInFlightMap = clearInFlightMap;
|
|
10
|
+
require("./types"); // for Request type
|
|
10
11
|
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
11
12
|
const env_1 = require("@pgpmjs/env");
|
|
12
13
|
const logger_1 = require("@pgpmjs/logger");
|
|
@@ -15,11 +16,10 @@ const graphile_function_bindings_1 = require("graphile-function-bindings");
|
|
|
15
16
|
const graphile_settings_1 = require("graphile-settings");
|
|
16
17
|
const pg_cache_1 = require("pg-cache");
|
|
17
18
|
const pg_env_1 = require("pg-env");
|
|
18
|
-
require("./types"); // for Request type
|
|
19
19
|
const observability_1 = require("../diagnostics/observability");
|
|
20
20
|
const api_errors_1 = require("../errors/api-errors");
|
|
21
|
-
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
22
21
|
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
22
|
+
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
23
23
|
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
24
24
|
const SAFE_ERROR_CODES = new Set([
|
|
25
25
|
// GraphQL standard
|
|
@@ -124,7 +124,7 @@ const SAFE_ERROR_CODES = new Set([
|
|
|
124
124
|
'23503', // foreign_key_violation
|
|
125
125
|
'23502', // not_null_violation
|
|
126
126
|
'23514', // check_violation
|
|
127
|
-
'23P01'
|
|
127
|
+
'23P01' // exclusion_violation
|
|
128
128
|
]);
|
|
129
129
|
/**
|
|
130
130
|
* Production-aware error masking function.
|
|
@@ -151,8 +151,8 @@ const maskError = (error) => {
|
|
|
151
151
|
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
152
152
|
extensions: {
|
|
153
153
|
code: 'INTERNAL_SERVER_ERROR',
|
|
154
|
-
errorId
|
|
155
|
-
}
|
|
154
|
+
errorId
|
|
155
|
+
}
|
|
156
156
|
};
|
|
157
157
|
};
|
|
158
158
|
// =============================================================================
|
|
@@ -213,24 +213,24 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
213
213
|
definitionsTable: m.definitionsTableName,
|
|
214
214
|
invocationsSchema: m.invocationsSchemaName,
|
|
215
215
|
invocationsTable: m.invocationsTableName,
|
|
216
|
-
invocationsEntityField: m.invocationsEntityField
|
|
217
|
-
}))
|
|
218
|
-
})
|
|
216
|
+
invocationsEntityField: m.invocationsEntityField
|
|
217
|
+
}))
|
|
218
|
+
})
|
|
219
219
|
]
|
|
220
|
-
: [])
|
|
220
|
+
: [])
|
|
221
221
|
],
|
|
222
222
|
pgServices: [
|
|
223
223
|
(0, graphile_settings_1.makePgService)({
|
|
224
224
|
pool,
|
|
225
|
-
schemas
|
|
226
|
-
})
|
|
225
|
+
schemas
|
|
226
|
+
})
|
|
227
227
|
],
|
|
228
228
|
grafserv: {
|
|
229
229
|
graphqlPath: '/graphql',
|
|
230
230
|
graphiqlPath: '/graphiql',
|
|
231
231
|
graphiql: true,
|
|
232
232
|
graphiqlOnGraphQLGET: false,
|
|
233
|
-
maskError
|
|
233
|
+
maskError
|
|
234
234
|
},
|
|
235
235
|
grafast: {
|
|
236
236
|
explain: process.env.NODE_ENV === 'development',
|
|
@@ -243,8 +243,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
243
243
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
244
244
|
}
|
|
245
245
|
// API provenance — which API surface this request arrived through.
|
|
246
|
-
// Derived server-side
|
|
247
|
-
//
|
|
246
|
+
// Derived server-side by resolving the hostname through the scoped
|
|
247
|
+
// routing plane (resolve_route -> api_id); never taken from
|
|
248
|
+
// client-supplied headers, body, or token payload.
|
|
248
249
|
if (req.api?.apiId) {
|
|
249
250
|
context['jwt.claims.api_id'] = req.api.apiId;
|
|
250
251
|
}
|
|
@@ -265,7 +266,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
265
266
|
role: roleName,
|
|
266
267
|
'jwt.claims.token_id': req.token.id,
|
|
267
268
|
'jwt.claims.user_id': req.token.user_id,
|
|
268
|
-
...context
|
|
269
|
+
...context
|
|
269
270
|
};
|
|
270
271
|
if (req.token.session_id) {
|
|
271
272
|
pgSettings['jwt.claims.session_id'] = req.token.session_id;
|
|
@@ -292,16 +293,16 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
292
293
|
}
|
|
293
294
|
const anonSettings = {
|
|
294
295
|
role: anonRole,
|
|
295
|
-
...context
|
|
296
|
+
...context
|
|
296
297
|
};
|
|
297
298
|
if (req?.requestId) {
|
|
298
299
|
anonSettings['request.id'] = req.requestId;
|
|
299
300
|
}
|
|
300
301
|
return {
|
|
301
|
-
pgSettings: anonSettings
|
|
302
|
+
pgSettings: anonSettings
|
|
302
303
|
};
|
|
303
|
-
}
|
|
304
|
-
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
305
306
|
};
|
|
306
307
|
};
|
|
307
308
|
const graphile = (opts) => {
|
|
@@ -364,7 +365,7 @@ const graphile = (opts) => {
|
|
|
364
365
|
log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
|
|
365
366
|
const pgConfig = (0, pg_env_1.getPgEnvOptions)({
|
|
366
367
|
...opts.pg,
|
|
367
|
-
database: dbname
|
|
368
|
+
database: dbname
|
|
368
369
|
});
|
|
369
370
|
// Route through pg-cache so the pool is tracked and can be cleaned up
|
|
370
371
|
// properly, preventing leaked connections during database teardown.
|
|
@@ -375,11 +376,11 @@ const graphile = (opts) => {
|
|
|
375
376
|
const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
376
377
|
cacheKey: key,
|
|
377
378
|
serviceKey: key,
|
|
378
|
-
databaseId: api.databaseId ?? null
|
|
379
|
+
databaseId: api.databaseId ?? null
|
|
379
380
|
}, () => (0, graphile_cache_1.createGraphileInstance)({
|
|
380
381
|
preset,
|
|
381
382
|
cacheKey: key,
|
|
382
|
-
enableRealtime: api.databaseSettings?.enableRealtime
|
|
383
|
+
enableRealtime: api.databaseSettings?.enableRealtime
|
|
383
384
|
}), { enabled: observabilityEnabled });
|
|
384
385
|
creating.set(key, creationPromise);
|
|
385
386
|
try {
|
|
@@ -392,7 +393,7 @@ const graphile = (opts) => {
|
|
|
392
393
|
log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
|
|
393
394
|
throw new api_errors_1.HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
394
395
|
cacheKey: key,
|
|
395
|
-
cause: error instanceof Error ? error.message : String(error)
|
|
396
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
396
397
|
});
|
|
397
398
|
}
|
|
398
399
|
finally {
|