@constructive-io/graphql-server 4.34.0 → 4.34.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 +45 -402
- package/middleware/api.js +45 -402
- package/package.json +3 -3
- package/types.d.ts +1 -124
package/esm/middleware/api.js
CHANGED
|
@@ -2,14 +2,18 @@ import { getNodeEnv } from '@pgpmjs/env';
|
|
|
2
2
|
import { Logger } from '@pgpmjs/logger';
|
|
3
3
|
import { svcCache } from '@pgpmjs/server-utils';
|
|
4
4
|
import { parseUrl } from '@constructive-io/url-domains';
|
|
5
|
+
import { createDefaultRegistry, } from '@constructive-io/express-context';
|
|
5
6
|
import { getPgPool } from 'pg-cache';
|
|
6
7
|
import errorPage50x from '../errors/50x';
|
|
7
8
|
import errorPage404Message from '../errors/404-message';
|
|
8
9
|
import './types';
|
|
9
10
|
const log = new Logger('api');
|
|
10
|
-
const isDev = () => getNodeEnv() === 'development';
|
|
11
11
|
// =============================================================================
|
|
12
|
-
// SQL
|
|
12
|
+
// Module Loader Registry (replaces inline SQL queries for per-db config)
|
|
13
|
+
// =============================================================================
|
|
14
|
+
const defaultRegistry = createDefaultRegistry();
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// SQL Queries (API resolution only — module queries now live in loaders)
|
|
13
17
|
// =============================================================================
|
|
14
18
|
const DOMAIN_LOOKUP_SQL = `
|
|
15
19
|
SELECT
|
|
@@ -69,153 +73,39 @@ const API_LIST_SQL = `
|
|
|
69
73
|
GROUP BY a.id, a.database_id, a.name, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
70
74
|
LIMIT 100
|
|
71
75
|
`;
|
|
72
|
-
const RLS_MODULE_SQL = `
|
|
73
|
-
SELECT data
|
|
74
|
-
FROM services_public.api_modules
|
|
75
|
-
WHERE api_id = $1 AND name = 'rls_module'
|
|
76
|
-
LIMIT 1
|
|
77
|
-
`;
|
|
78
|
-
const RLS_SETTINGS_SQL = `
|
|
79
|
-
SELECT
|
|
80
|
-
auth_schema.schema_name AS authenticate_schema,
|
|
81
|
-
role_schema.schema_name AS role_schema,
|
|
82
|
-
auth_fn.name AS authenticate,
|
|
83
|
-
auth_strict_fn.name AS authenticate_strict,
|
|
84
|
-
role_fn.name AS current_role,
|
|
85
|
-
role_id_fn.name AS current_role_id,
|
|
86
|
-
ua_fn.name AS current_user_agent,
|
|
87
|
-
ip_fn.name AS current_ip_address
|
|
88
|
-
FROM services_public.rls_settings rs
|
|
89
|
-
LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id
|
|
90
|
-
LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id
|
|
91
|
-
LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id
|
|
92
|
-
LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id
|
|
93
|
-
LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id
|
|
94
|
-
LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id
|
|
95
|
-
LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id
|
|
96
|
-
LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id
|
|
97
|
-
WHERE rs.database_id = $1
|
|
98
|
-
LIMIT 1
|
|
99
|
-
`;
|
|
100
76
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* the schema name + table name without touching private schemas.
|
|
77
|
+
* Build a LoaderContext from the API row and options.
|
|
78
|
+
* This is used to resolve per-database module settings via the loader registry.
|
|
104
79
|
*/
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
80
|
+
const buildLoaderContext = (servicesPool, opts, row) => ({
|
|
81
|
+
servicesPool,
|
|
82
|
+
tenantPool: getPgPool({ ...opts.pg, database: row.dbname }),
|
|
83
|
+
databaseId: row.database_id,
|
|
84
|
+
apiId: row.api_id,
|
|
85
|
+
dbname: row.dbname,
|
|
86
|
+
});
|
|
111
87
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
88
|
+
* Resolve all per-database module settings in parallel via the loader registry.
|
|
89
|
+
* Each loader independently caches by databaseId — repeated calls are cheap.
|
|
114
90
|
*/
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
LIMIT 1
|
|
134
|
-
`;
|
|
135
|
-
const CORS_SETTINGS_DB_DEFAULT_SQL = `
|
|
136
|
-
SELECT allowed_origins
|
|
137
|
-
FROM services_public.cors_settings
|
|
138
|
-
WHERE database_id = $1 AND api_id IS NULL
|
|
139
|
-
LIMIT 1
|
|
140
|
-
`;
|
|
141
|
-
const CORS_MODULE_SQL = `
|
|
142
|
-
SELECT data
|
|
143
|
-
FROM services_public.api_modules
|
|
144
|
-
WHERE api_id = $1 AND name = 'cors'
|
|
145
|
-
LIMIT 1
|
|
146
|
-
`;
|
|
147
|
-
const PUBKEY_SETTINGS_SQL = `
|
|
148
|
-
SELECT
|
|
149
|
-
s.schema_name AS schema,
|
|
150
|
-
ps.crypto_network,
|
|
151
|
-
sign_up_fn.name AS sign_up_with_key,
|
|
152
|
-
sign_in_req_fn.name AS sign_in_request_challenge,
|
|
153
|
-
sign_in_fail_fn.name AS sign_in_record_failure,
|
|
154
|
-
sign_in_fn.name AS sign_in_with_challenge
|
|
155
|
-
FROM services_public.pubkey_settings ps
|
|
156
|
-
LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
|
|
157
|
-
LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
|
|
158
|
-
LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
|
|
159
|
-
LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
|
|
160
|
-
LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
|
|
161
|
-
WHERE ps.database_id = $1
|
|
162
|
-
LIMIT 1
|
|
163
|
-
`;
|
|
164
|
-
const PUBKEY_MODULE_SQL = `
|
|
165
|
-
SELECT data
|
|
166
|
-
FROM services_public.api_modules
|
|
167
|
-
WHERE api_id = $1 AND name = 'pubkey_challenge'
|
|
168
|
-
LIMIT 1
|
|
169
|
-
`;
|
|
170
|
-
const WEBAUTHN_SETTINGS_SQL = `
|
|
171
|
-
SELECT
|
|
172
|
-
s.schema_name AS schema,
|
|
173
|
-
cred_s.schema_name AS credentials_schema,
|
|
174
|
-
sess_s.schema_name AS sessions_schema,
|
|
175
|
-
sec_s.schema_name AS session_secrets_schema,
|
|
176
|
-
ws.rp_id,
|
|
177
|
-
ws.rp_name,
|
|
178
|
-
ws.origin_allowlist,
|
|
179
|
-
ws.attestation_type,
|
|
180
|
-
ws.require_user_verification,
|
|
181
|
-
ws.resident_key,
|
|
182
|
-
ws.challenge_expiry_seconds
|
|
183
|
-
FROM services_public.webauthn_settings ws
|
|
184
|
-
LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
|
|
185
|
-
LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
|
|
186
|
-
LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
|
|
187
|
-
LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
|
|
188
|
-
WHERE ws.database_id = $1
|
|
189
|
-
LIMIT 1
|
|
190
|
-
`;
|
|
191
|
-
const DATABASE_SETTINGS_SQL = `
|
|
192
|
-
SELECT
|
|
193
|
-
ds.enable_aggregates,
|
|
194
|
-
ds.enable_postgis,
|
|
195
|
-
ds.enable_search,
|
|
196
|
-
ds.enable_direct_uploads,
|
|
197
|
-
ds.enable_presigned_uploads,
|
|
198
|
-
ds.enable_many_to_many,
|
|
199
|
-
ds.enable_connection_filter,
|
|
200
|
-
ds.enable_ltree,
|
|
201
|
-
ds.enable_llm,
|
|
202
|
-
ds.enable_bulk,
|
|
203
|
-
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
|
|
204
|
-
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
|
|
205
|
-
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
|
|
206
|
-
COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
|
|
207
|
-
COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
|
|
208
|
-
COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
|
|
209
|
-
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
|
|
210
|
-
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
|
|
211
|
-
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
|
|
212
|
-
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
|
|
213
|
-
COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
|
|
214
|
-
FROM services_public.database_settings ds
|
|
215
|
-
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
|
|
216
|
-
WHERE ds.database_id = $1
|
|
217
|
-
LIMIT 1
|
|
218
|
-
`;
|
|
91
|
+
const resolveModuleSettings = async (registry, ctx) => {
|
|
92
|
+
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings,] = await Promise.all([
|
|
93
|
+
registry.resolve('rlsModule', ctx),
|
|
94
|
+
registry.resolve('authSettings', ctx),
|
|
95
|
+
registry.resolve('corsOrigins', ctx),
|
|
96
|
+
registry.resolve('databaseSettings', ctx),
|
|
97
|
+
registry.resolve('pubkeyChallengeSettings', ctx),
|
|
98
|
+
registry.resolve('webauthnSettings', ctx),
|
|
99
|
+
]);
|
|
100
|
+
return {
|
|
101
|
+
rlsModule,
|
|
102
|
+
authSettings,
|
|
103
|
+
corsOrigins,
|
|
104
|
+
databaseSettings,
|
|
105
|
+
pubkeyChallengeSettings,
|
|
106
|
+
webauthnSettings,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
219
109
|
// =============================================================================
|
|
220
110
|
// Helpers
|
|
221
111
|
// =============================================================================
|
|
@@ -249,63 +139,6 @@ export const getSvcKey = (opts, req) => {
|
|
|
249
139
|
}
|
|
250
140
|
return baseKey;
|
|
251
141
|
};
|
|
252
|
-
const toRlsModule = (row) => {
|
|
253
|
-
if (!row?.data)
|
|
254
|
-
return undefined;
|
|
255
|
-
const d = row.data;
|
|
256
|
-
return {
|
|
257
|
-
authenticate: d.authenticate,
|
|
258
|
-
authenticateStrict: d.authenticate_strict,
|
|
259
|
-
privateSchema: {
|
|
260
|
-
schemaName: d.authenticate_schema,
|
|
261
|
-
},
|
|
262
|
-
publicSchema: {
|
|
263
|
-
schemaName: d.role_schema,
|
|
264
|
-
},
|
|
265
|
-
currentRole: d.current_role,
|
|
266
|
-
currentRoleId: d.current_role_id,
|
|
267
|
-
currentIpAddress: d.current_ip_address,
|
|
268
|
-
currentUserAgent: d.current_user_agent,
|
|
269
|
-
};
|
|
270
|
-
};
|
|
271
|
-
const toRlsModuleFromSettings = (row) => {
|
|
272
|
-
if (!row)
|
|
273
|
-
return undefined;
|
|
274
|
-
// If metaschema_public.function rows are missing (e.g. trigger was skipped
|
|
275
|
-
// during migration), the LEFT JOINs resolve NULL. Return undefined so the
|
|
276
|
-
// caller falls back to the legacy api_modules lookup.
|
|
277
|
-
if (!row.authenticate || !row.authenticate_schema)
|
|
278
|
-
return undefined;
|
|
279
|
-
return {
|
|
280
|
-
authenticate: row.authenticate,
|
|
281
|
-
authenticateStrict: row.authenticate_strict,
|
|
282
|
-
privateSchema: {
|
|
283
|
-
schemaName: row.authenticate_schema,
|
|
284
|
-
},
|
|
285
|
-
publicSchema: {
|
|
286
|
-
schemaName: row.role_schema,
|
|
287
|
-
},
|
|
288
|
-
currentRole: row.current_role,
|
|
289
|
-
currentRoleId: row.current_role_id,
|
|
290
|
-
currentIpAddress: row.current_ip_address,
|
|
291
|
-
currentUserAgent: row.current_user_agent,
|
|
292
|
-
};
|
|
293
|
-
};
|
|
294
|
-
const toAuthSettings = (row) => {
|
|
295
|
-
if (!row)
|
|
296
|
-
return undefined;
|
|
297
|
-
return {
|
|
298
|
-
cookieSecure: row.cookie_secure,
|
|
299
|
-
cookieSamesite: row.cookie_samesite,
|
|
300
|
-
cookieDomain: row.cookie_domain,
|
|
301
|
-
cookieHttponly: row.cookie_httponly,
|
|
302
|
-
cookieMaxAge: row.cookie_max_age,
|
|
303
|
-
cookiePath: row.cookie_path,
|
|
304
|
-
rememberMeDuration: row.remember_me_duration,
|
|
305
|
-
enableCaptcha: row.enable_captcha,
|
|
306
|
-
captchaSiteKey: row.captcha_site_key,
|
|
307
|
-
};
|
|
308
|
-
};
|
|
309
142
|
const toApiStructure = (row, opts, settings = {}) => ({
|
|
310
143
|
apiId: row.api_id,
|
|
311
144
|
dbname: row.dbname || opts.pg?.database || '',
|
|
@@ -317,7 +150,7 @@ const toApiStructure = (row, opts, settings = {}) => ({
|
|
|
317
150
|
domains: [],
|
|
318
151
|
databaseId: row.database_id,
|
|
319
152
|
isPublic: row.is_public,
|
|
320
|
-
authSettings:
|
|
153
|
+
authSettings: settings.authSettings,
|
|
321
154
|
corsOrigins: settings.corsOrigins,
|
|
322
155
|
databaseSettings: settings.databaseSettings,
|
|
323
156
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
@@ -334,7 +167,7 @@ const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
|
334
167
|
isPublic: false,
|
|
335
168
|
});
|
|
336
169
|
// =============================================================================
|
|
337
|
-
// Database Queries
|
|
170
|
+
// Database Queries (API resolution only)
|
|
338
171
|
// =============================================================================
|
|
339
172
|
const validateSchemata = async (pool, schemas) => {
|
|
340
173
|
const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
|
|
@@ -352,184 +185,6 @@ const queryApiList = async (pool, isPublic) => {
|
|
|
352
185
|
const result = await pool.query(API_LIST_SQL, [isPublic]);
|
|
353
186
|
return result.rows;
|
|
354
187
|
};
|
|
355
|
-
const queryRlsSettings = async (pool, databaseId) => {
|
|
356
|
-
try {
|
|
357
|
-
const result = await pool.query(RLS_SETTINGS_SQL, [databaseId]);
|
|
358
|
-
return toRlsModuleFromSettings(result.rows[0] ?? null);
|
|
359
|
-
}
|
|
360
|
-
catch (e) {
|
|
361
|
-
log.warn(`[rls-settings] Failed to load RLS settings: ${e.message}`);
|
|
362
|
-
return undefined;
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
const queryRlsModuleLegacy = async (pool, apiId) => {
|
|
366
|
-
const result = await pool.query(RLS_MODULE_SQL, [apiId]);
|
|
367
|
-
return toRlsModule(result.rows[0] ?? null);
|
|
368
|
-
};
|
|
369
|
-
const queryRlsModule = async (pool, databaseId, apiId) => {
|
|
370
|
-
const fromSettings = await queryRlsSettings(pool, databaseId);
|
|
371
|
-
if (fromSettings)
|
|
372
|
-
return fromSettings;
|
|
373
|
-
return queryRlsModuleLegacy(pool, apiId);
|
|
374
|
-
};
|
|
375
|
-
// -- CORS --
|
|
376
|
-
const queryCorsSettings = async (pool, databaseId, apiId) => {
|
|
377
|
-
try {
|
|
378
|
-
if (apiId) {
|
|
379
|
-
const perApi = await pool.query(CORS_SETTINGS_SQL, [databaseId, apiId]);
|
|
380
|
-
if (perApi.rows[0])
|
|
381
|
-
return perApi.rows[0].allowed_origins;
|
|
382
|
-
}
|
|
383
|
-
const dbDefault = await pool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
|
|
384
|
-
return dbDefault.rows[0]?.allowed_origins;
|
|
385
|
-
}
|
|
386
|
-
catch (e) {
|
|
387
|
-
log.warn(`[cors-settings] Failed to load CORS settings: ${e.message}`);
|
|
388
|
-
return undefined;
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
const queryCorsModuleLegacy = async (pool, apiId) => {
|
|
392
|
-
const result = await pool.query(CORS_MODULE_SQL, [apiId]);
|
|
393
|
-
return result.rows[0]?.data?.urls;
|
|
394
|
-
};
|
|
395
|
-
const queryCorsOrigins = async (pool, databaseId, apiId) => {
|
|
396
|
-
const fromSettings = await queryCorsSettings(pool, databaseId, apiId);
|
|
397
|
-
if (fromSettings)
|
|
398
|
-
return fromSettings;
|
|
399
|
-
if (apiId)
|
|
400
|
-
return queryCorsModuleLegacy(pool, apiId);
|
|
401
|
-
return undefined;
|
|
402
|
-
};
|
|
403
|
-
// -- Pubkey --
|
|
404
|
-
const toPubkeyChallengeSettings = (row) => {
|
|
405
|
-
if (!row?.schema || !row?.sign_up_with_key)
|
|
406
|
-
return undefined;
|
|
407
|
-
return {
|
|
408
|
-
schema: row.schema,
|
|
409
|
-
cryptoNetwork: row.crypto_network,
|
|
410
|
-
signUpWithKey: row.sign_up_with_key,
|
|
411
|
-
signInRequestChallenge: row.sign_in_request_challenge,
|
|
412
|
-
signInRecordFailure: row.sign_in_record_failure,
|
|
413
|
-
signInWithChallenge: row.sign_in_with_challenge,
|
|
414
|
-
};
|
|
415
|
-
};
|
|
416
|
-
const toPubkeyChallengeFromModule = (row) => {
|
|
417
|
-
if (!row?.data?.schema)
|
|
418
|
-
return undefined;
|
|
419
|
-
const d = row.data;
|
|
420
|
-
return {
|
|
421
|
-
schema: d.schema,
|
|
422
|
-
cryptoNetwork: d.crypto_network,
|
|
423
|
-
signUpWithKey: d.sign_up_with_key,
|
|
424
|
-
signInRequestChallenge: d.sign_in_request_challenge,
|
|
425
|
-
signInRecordFailure: d.sign_in_record_failure,
|
|
426
|
-
signInWithChallenge: d.sign_in_with_challenge,
|
|
427
|
-
};
|
|
428
|
-
};
|
|
429
|
-
const queryPubkeySettings = async (pool, databaseId) => {
|
|
430
|
-
try {
|
|
431
|
-
const result = await pool.query(PUBKEY_SETTINGS_SQL, [databaseId]);
|
|
432
|
-
return toPubkeyChallengeSettings(result.rows[0] ?? null);
|
|
433
|
-
}
|
|
434
|
-
catch (e) {
|
|
435
|
-
log.warn(`[pubkey-settings] Failed to load pubkey challenge settings: ${e.message}`);
|
|
436
|
-
return undefined;
|
|
437
|
-
}
|
|
438
|
-
};
|
|
439
|
-
const queryPubkeyModuleLegacy = async (pool, apiId) => {
|
|
440
|
-
const result = await pool.query(PUBKEY_MODULE_SQL, [apiId]);
|
|
441
|
-
return toPubkeyChallengeFromModule(result.rows[0] ?? null);
|
|
442
|
-
};
|
|
443
|
-
const queryPubkeyChallenge = async (pool, databaseId, apiId) => {
|
|
444
|
-
const fromSettings = await queryPubkeySettings(pool, databaseId);
|
|
445
|
-
if (fromSettings)
|
|
446
|
-
return fromSettings;
|
|
447
|
-
if (apiId)
|
|
448
|
-
return queryPubkeyModuleLegacy(pool, apiId);
|
|
449
|
-
return undefined;
|
|
450
|
-
};
|
|
451
|
-
// -- WebAuthn --
|
|
452
|
-
const toWebauthnSettings = (row) => {
|
|
453
|
-
if (!row?.schema)
|
|
454
|
-
return undefined;
|
|
455
|
-
return {
|
|
456
|
-
schema: row.schema,
|
|
457
|
-
credentialsSchema: row.credentials_schema,
|
|
458
|
-
sessionsSchema: row.sessions_schema,
|
|
459
|
-
sessionSecretsSchema: row.session_secrets_schema,
|
|
460
|
-
rpId: row.rp_id,
|
|
461
|
-
rpName: row.rp_name,
|
|
462
|
-
originAllowlist: row.origin_allowlist,
|
|
463
|
-
attestationType: row.attestation_type,
|
|
464
|
-
requireUserVerification: row.require_user_verification,
|
|
465
|
-
residentKey: row.resident_key,
|
|
466
|
-
challengeExpirySeconds: row.challenge_expiry_seconds,
|
|
467
|
-
};
|
|
468
|
-
};
|
|
469
|
-
const queryWebauthnSettings = async (pool, databaseId) => {
|
|
470
|
-
try {
|
|
471
|
-
const result = await pool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]);
|
|
472
|
-
return toWebauthnSettings(result.rows[0] ?? null);
|
|
473
|
-
}
|
|
474
|
-
catch (e) {
|
|
475
|
-
log.warn(`[webauthn-settings] Failed to load webauthn settings: ${e.message}`);
|
|
476
|
-
return undefined;
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
// -- Database Settings (feature flags) --
|
|
480
|
-
const toDatabaseSettings = (row) => {
|
|
481
|
-
if (!row)
|
|
482
|
-
return undefined;
|
|
483
|
-
return {
|
|
484
|
-
enableAggregates: row.resolved_enable_aggregates,
|
|
485
|
-
enablePostgis: row.resolved_enable_postgis,
|
|
486
|
-
enableSearch: row.resolved_enable_search,
|
|
487
|
-
enableDirectUploads: row.resolved_enable_direct_uploads,
|
|
488
|
-
enablePresignedUploads: row.resolved_enable_presigned_uploads,
|
|
489
|
-
enableManyToMany: row.resolved_enable_many_to_many,
|
|
490
|
-
enableConnectionFilter: row.resolved_enable_connection_filter,
|
|
491
|
-
enableLtree: row.resolved_enable_ltree,
|
|
492
|
-
enableLlm: row.resolved_enable_llm,
|
|
493
|
-
enableRealtime: row.resolved_enable_realtime,
|
|
494
|
-
enableBulk: row.resolved_enable_bulk,
|
|
495
|
-
};
|
|
496
|
-
};
|
|
497
|
-
const queryDatabaseSettings = async (pool, databaseId, apiId) => {
|
|
498
|
-
try {
|
|
499
|
-
const result = await pool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
|
|
500
|
-
return toDatabaseSettings(result.rows[0] ?? null);
|
|
501
|
-
}
|
|
502
|
-
catch (e) {
|
|
503
|
-
log.warn(`[database-settings] Failed to load database settings: ${e.message}`);
|
|
504
|
-
return undefined;
|
|
505
|
-
}
|
|
506
|
-
};
|
|
507
|
-
/**
|
|
508
|
-
* Load server-relevant auth settings from the tenant DB.
|
|
509
|
-
* Discovers the auth settings table dynamically by joining
|
|
510
|
-
* metaschema_modules_public.sessions_module with metaschema_public.schema
|
|
511
|
-
* (both public schemas). Fails gracefully if modules or table don't exist yet.
|
|
512
|
-
*/
|
|
513
|
-
const queryAuthSettings = async (opts, dbname) => {
|
|
514
|
-
try {
|
|
515
|
-
const tenantPool = getPgPool({ ...opts.pg, database: dbname });
|
|
516
|
-
// Discover the auth settings schema + table name from public metaschema tables
|
|
517
|
-
const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
|
|
518
|
-
const resolved = discovery.rows[0];
|
|
519
|
-
if (!resolved) {
|
|
520
|
-
log.debug('[auth-settings] No sessions_module row found in tenant DB');
|
|
521
|
-
return null;
|
|
522
|
-
}
|
|
523
|
-
// Query the discovered auth settings table
|
|
524
|
-
const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
|
|
525
|
-
return result.rows[0] ?? null;
|
|
526
|
-
}
|
|
527
|
-
catch (e) {
|
|
528
|
-
// Table/module may not exist yet if the 2FA migration hasn't been applied
|
|
529
|
-
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
188
|
// =============================================================================
|
|
534
189
|
// Resolution Logic
|
|
535
190
|
// =============================================================================
|
|
@@ -580,16 +235,10 @@ const resolveApiNameHeader = async (ctx) => {
|
|
|
580
235
|
log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
|
|
581
236
|
return null;
|
|
582
237
|
}
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
queryDatabaseSettings(pool, row.database_id, row.api_id),
|
|
588
|
-
queryPubkeyChallenge(pool, row.database_id, row.api_id),
|
|
589
|
-
queryWebauthnSettings(pool, row.database_id),
|
|
590
|
-
]);
|
|
591
|
-
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
|
|
592
|
-
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
|
|
238
|
+
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
239
|
+
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
240
|
+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
241
|
+
return toApiStructure(row, opts, settings);
|
|
593
242
|
};
|
|
594
243
|
const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
595
244
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
@@ -603,16 +252,10 @@ const resolveDomainLookup = async (ctx) => {
|
|
|
603
252
|
log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
|
|
604
253
|
return null;
|
|
605
254
|
}
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
queryDatabaseSettings(pool, row.database_id, row.api_id),
|
|
611
|
-
queryPubkeyChallenge(pool, row.database_id, row.api_id),
|
|
612
|
-
queryWebauthnSettings(pool, row.database_id),
|
|
613
|
-
]);
|
|
614
|
-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
|
|
615
|
-
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
|
|
255
|
+
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
256
|
+
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
257
|
+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
258
|
+
return toApiStructure(row, opts, settings);
|
|
616
259
|
};
|
|
617
260
|
const buildDevFallbackError = async (ctx, req) => {
|
|
618
261
|
if (getNodeEnv() !== 'development')
|
package/middleware/api.js
CHANGED
|
@@ -8,14 +8,18 @@ const env_1 = require("@pgpmjs/env");
|
|
|
8
8
|
const logger_1 = require("@pgpmjs/logger");
|
|
9
9
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
10
10
|
const url_domains_1 = require("@constructive-io/url-domains");
|
|
11
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
11
12
|
const pg_cache_1 = require("pg-cache");
|
|
12
13
|
const _50x_1 = __importDefault(require("../errors/50x"));
|
|
13
14
|
const _404_message_1 = __importDefault(require("../errors/404-message"));
|
|
14
15
|
require("./types");
|
|
15
16
|
const log = new logger_1.Logger('api');
|
|
16
|
-
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
17
17
|
// =============================================================================
|
|
18
|
-
// SQL
|
|
18
|
+
// Module Loader Registry (replaces inline SQL queries for per-db config)
|
|
19
|
+
// =============================================================================
|
|
20
|
+
const defaultRegistry = (0, express_context_1.createDefaultRegistry)();
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// SQL Queries (API resolution only — module queries now live in loaders)
|
|
19
23
|
// =============================================================================
|
|
20
24
|
const DOMAIN_LOOKUP_SQL = `
|
|
21
25
|
SELECT
|
|
@@ -75,153 +79,39 @@ const API_LIST_SQL = `
|
|
|
75
79
|
GROUP BY a.id, a.database_id, a.name, a.dbname, a.role_name, a.anon_role, a.is_public
|
|
76
80
|
LIMIT 100
|
|
77
81
|
`;
|
|
78
|
-
const RLS_MODULE_SQL = `
|
|
79
|
-
SELECT data
|
|
80
|
-
FROM services_public.api_modules
|
|
81
|
-
WHERE api_id = $1 AND name = 'rls_module'
|
|
82
|
-
LIMIT 1
|
|
83
|
-
`;
|
|
84
|
-
const RLS_SETTINGS_SQL = `
|
|
85
|
-
SELECT
|
|
86
|
-
auth_schema.schema_name AS authenticate_schema,
|
|
87
|
-
role_schema.schema_name AS role_schema,
|
|
88
|
-
auth_fn.name AS authenticate,
|
|
89
|
-
auth_strict_fn.name AS authenticate_strict,
|
|
90
|
-
role_fn.name AS current_role,
|
|
91
|
-
role_id_fn.name AS current_role_id,
|
|
92
|
-
ua_fn.name AS current_user_agent,
|
|
93
|
-
ip_fn.name AS current_ip_address
|
|
94
|
-
FROM services_public.rls_settings rs
|
|
95
|
-
LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id
|
|
96
|
-
LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id
|
|
97
|
-
LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id
|
|
98
|
-
LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id
|
|
99
|
-
LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id
|
|
100
|
-
LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id
|
|
101
|
-
LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id
|
|
102
|
-
LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id
|
|
103
|
-
WHERE rs.database_id = $1
|
|
104
|
-
LIMIT 1
|
|
105
|
-
`;
|
|
106
82
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* the schema name + table name without touching private schemas.
|
|
83
|
+
* Build a LoaderContext from the API row and options.
|
|
84
|
+
* This is used to resolve per-database module settings via the loader registry.
|
|
110
85
|
*/
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
86
|
+
const buildLoaderContext = (servicesPool, opts, row) => ({
|
|
87
|
+
servicesPool,
|
|
88
|
+
tenantPool: (0, pg_cache_1.getPgPool)({ ...opts.pg, database: row.dbname }),
|
|
89
|
+
databaseId: row.database_id,
|
|
90
|
+
apiId: row.api_id,
|
|
91
|
+
dbname: row.dbname,
|
|
92
|
+
});
|
|
117
93
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
94
|
+
* Resolve all per-database module settings in parallel via the loader registry.
|
|
95
|
+
* Each loader independently caches by databaseId — repeated calls are cheap.
|
|
120
96
|
*/
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
LIMIT 1
|
|
140
|
-
`;
|
|
141
|
-
const CORS_SETTINGS_DB_DEFAULT_SQL = `
|
|
142
|
-
SELECT allowed_origins
|
|
143
|
-
FROM services_public.cors_settings
|
|
144
|
-
WHERE database_id = $1 AND api_id IS NULL
|
|
145
|
-
LIMIT 1
|
|
146
|
-
`;
|
|
147
|
-
const CORS_MODULE_SQL = `
|
|
148
|
-
SELECT data
|
|
149
|
-
FROM services_public.api_modules
|
|
150
|
-
WHERE api_id = $1 AND name = 'cors'
|
|
151
|
-
LIMIT 1
|
|
152
|
-
`;
|
|
153
|
-
const PUBKEY_SETTINGS_SQL = `
|
|
154
|
-
SELECT
|
|
155
|
-
s.schema_name AS schema,
|
|
156
|
-
ps.crypto_network,
|
|
157
|
-
sign_up_fn.name AS sign_up_with_key,
|
|
158
|
-
sign_in_req_fn.name AS sign_in_request_challenge,
|
|
159
|
-
sign_in_fail_fn.name AS sign_in_record_failure,
|
|
160
|
-
sign_in_fn.name AS sign_in_with_challenge
|
|
161
|
-
FROM services_public.pubkey_settings ps
|
|
162
|
-
LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
|
|
163
|
-
LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
|
|
164
|
-
LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
|
|
165
|
-
LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
|
|
166
|
-
LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
|
|
167
|
-
WHERE ps.database_id = $1
|
|
168
|
-
LIMIT 1
|
|
169
|
-
`;
|
|
170
|
-
const PUBKEY_MODULE_SQL = `
|
|
171
|
-
SELECT data
|
|
172
|
-
FROM services_public.api_modules
|
|
173
|
-
WHERE api_id = $1 AND name = 'pubkey_challenge'
|
|
174
|
-
LIMIT 1
|
|
175
|
-
`;
|
|
176
|
-
const WEBAUTHN_SETTINGS_SQL = `
|
|
177
|
-
SELECT
|
|
178
|
-
s.schema_name AS schema,
|
|
179
|
-
cred_s.schema_name AS credentials_schema,
|
|
180
|
-
sess_s.schema_name AS sessions_schema,
|
|
181
|
-
sec_s.schema_name AS session_secrets_schema,
|
|
182
|
-
ws.rp_id,
|
|
183
|
-
ws.rp_name,
|
|
184
|
-
ws.origin_allowlist,
|
|
185
|
-
ws.attestation_type,
|
|
186
|
-
ws.require_user_verification,
|
|
187
|
-
ws.resident_key,
|
|
188
|
-
ws.challenge_expiry_seconds
|
|
189
|
-
FROM services_public.webauthn_settings ws
|
|
190
|
-
LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
|
|
191
|
-
LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
|
|
192
|
-
LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
|
|
193
|
-
LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
|
|
194
|
-
WHERE ws.database_id = $1
|
|
195
|
-
LIMIT 1
|
|
196
|
-
`;
|
|
197
|
-
const DATABASE_SETTINGS_SQL = `
|
|
198
|
-
SELECT
|
|
199
|
-
ds.enable_aggregates,
|
|
200
|
-
ds.enable_postgis,
|
|
201
|
-
ds.enable_search,
|
|
202
|
-
ds.enable_direct_uploads,
|
|
203
|
-
ds.enable_presigned_uploads,
|
|
204
|
-
ds.enable_many_to_many,
|
|
205
|
-
ds.enable_connection_filter,
|
|
206
|
-
ds.enable_ltree,
|
|
207
|
-
ds.enable_llm,
|
|
208
|
-
ds.enable_bulk,
|
|
209
|
-
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
|
|
210
|
-
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
|
|
211
|
-
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
|
|
212
|
-
COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
|
|
213
|
-
COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
|
|
214
|
-
COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
|
|
215
|
-
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
|
|
216
|
-
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
|
|
217
|
-
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
|
|
218
|
-
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
|
|
219
|
-
COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
|
|
220
|
-
FROM services_public.database_settings ds
|
|
221
|
-
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
|
|
222
|
-
WHERE ds.database_id = $1
|
|
223
|
-
LIMIT 1
|
|
224
|
-
`;
|
|
97
|
+
const resolveModuleSettings = async (registry, ctx) => {
|
|
98
|
+
const [rlsModule, authSettings, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings,] = await Promise.all([
|
|
99
|
+
registry.resolve('rlsModule', ctx),
|
|
100
|
+
registry.resolve('authSettings', ctx),
|
|
101
|
+
registry.resolve('corsOrigins', ctx),
|
|
102
|
+
registry.resolve('databaseSettings', ctx),
|
|
103
|
+
registry.resolve('pubkeyChallengeSettings', ctx),
|
|
104
|
+
registry.resolve('webauthnSettings', ctx),
|
|
105
|
+
]);
|
|
106
|
+
return {
|
|
107
|
+
rlsModule,
|
|
108
|
+
authSettings,
|
|
109
|
+
corsOrigins,
|
|
110
|
+
databaseSettings,
|
|
111
|
+
pubkeyChallengeSettings,
|
|
112
|
+
webauthnSettings,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
225
115
|
// =============================================================================
|
|
226
116
|
// Helpers
|
|
227
117
|
// =============================================================================
|
|
@@ -257,63 +147,6 @@ const getSvcKey = (opts, req) => {
|
|
|
257
147
|
return baseKey;
|
|
258
148
|
};
|
|
259
149
|
exports.getSvcKey = getSvcKey;
|
|
260
|
-
const toRlsModule = (row) => {
|
|
261
|
-
if (!row?.data)
|
|
262
|
-
return undefined;
|
|
263
|
-
const d = row.data;
|
|
264
|
-
return {
|
|
265
|
-
authenticate: d.authenticate,
|
|
266
|
-
authenticateStrict: d.authenticate_strict,
|
|
267
|
-
privateSchema: {
|
|
268
|
-
schemaName: d.authenticate_schema,
|
|
269
|
-
},
|
|
270
|
-
publicSchema: {
|
|
271
|
-
schemaName: d.role_schema,
|
|
272
|
-
},
|
|
273
|
-
currentRole: d.current_role,
|
|
274
|
-
currentRoleId: d.current_role_id,
|
|
275
|
-
currentIpAddress: d.current_ip_address,
|
|
276
|
-
currentUserAgent: d.current_user_agent,
|
|
277
|
-
};
|
|
278
|
-
};
|
|
279
|
-
const toRlsModuleFromSettings = (row) => {
|
|
280
|
-
if (!row)
|
|
281
|
-
return undefined;
|
|
282
|
-
// If metaschema_public.function rows are missing (e.g. trigger was skipped
|
|
283
|
-
// during migration), the LEFT JOINs resolve NULL. Return undefined so the
|
|
284
|
-
// caller falls back to the legacy api_modules lookup.
|
|
285
|
-
if (!row.authenticate || !row.authenticate_schema)
|
|
286
|
-
return undefined;
|
|
287
|
-
return {
|
|
288
|
-
authenticate: row.authenticate,
|
|
289
|
-
authenticateStrict: row.authenticate_strict,
|
|
290
|
-
privateSchema: {
|
|
291
|
-
schemaName: row.authenticate_schema,
|
|
292
|
-
},
|
|
293
|
-
publicSchema: {
|
|
294
|
-
schemaName: row.role_schema,
|
|
295
|
-
},
|
|
296
|
-
currentRole: row.current_role,
|
|
297
|
-
currentRoleId: row.current_role_id,
|
|
298
|
-
currentIpAddress: row.current_ip_address,
|
|
299
|
-
currentUserAgent: row.current_user_agent,
|
|
300
|
-
};
|
|
301
|
-
};
|
|
302
|
-
const toAuthSettings = (row) => {
|
|
303
|
-
if (!row)
|
|
304
|
-
return undefined;
|
|
305
|
-
return {
|
|
306
|
-
cookieSecure: row.cookie_secure,
|
|
307
|
-
cookieSamesite: row.cookie_samesite,
|
|
308
|
-
cookieDomain: row.cookie_domain,
|
|
309
|
-
cookieHttponly: row.cookie_httponly,
|
|
310
|
-
cookieMaxAge: row.cookie_max_age,
|
|
311
|
-
cookiePath: row.cookie_path,
|
|
312
|
-
rememberMeDuration: row.remember_me_duration,
|
|
313
|
-
enableCaptcha: row.enable_captcha,
|
|
314
|
-
captchaSiteKey: row.captcha_site_key,
|
|
315
|
-
};
|
|
316
|
-
};
|
|
317
150
|
const toApiStructure = (row, opts, settings = {}) => ({
|
|
318
151
|
apiId: row.api_id,
|
|
319
152
|
dbname: row.dbname || opts.pg?.database || '',
|
|
@@ -325,7 +158,7 @@ const toApiStructure = (row, opts, settings = {}) => ({
|
|
|
325
158
|
domains: [],
|
|
326
159
|
databaseId: row.database_id,
|
|
327
160
|
isPublic: row.is_public,
|
|
328
|
-
authSettings:
|
|
161
|
+
authSettings: settings.authSettings,
|
|
329
162
|
corsOrigins: settings.corsOrigins,
|
|
330
163
|
databaseSettings: settings.databaseSettings,
|
|
331
164
|
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
|
|
@@ -342,7 +175,7 @@ const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
|
342
175
|
isPublic: false,
|
|
343
176
|
});
|
|
344
177
|
// =============================================================================
|
|
345
|
-
// Database Queries
|
|
178
|
+
// Database Queries (API resolution only)
|
|
346
179
|
// =============================================================================
|
|
347
180
|
const validateSchemata = async (pool, schemas) => {
|
|
348
181
|
const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
|
|
@@ -360,184 +193,6 @@ const queryApiList = async (pool, isPublic) => {
|
|
|
360
193
|
const result = await pool.query(API_LIST_SQL, [isPublic]);
|
|
361
194
|
return result.rows;
|
|
362
195
|
};
|
|
363
|
-
const queryRlsSettings = async (pool, databaseId) => {
|
|
364
|
-
try {
|
|
365
|
-
const result = await pool.query(RLS_SETTINGS_SQL, [databaseId]);
|
|
366
|
-
return toRlsModuleFromSettings(result.rows[0] ?? null);
|
|
367
|
-
}
|
|
368
|
-
catch (e) {
|
|
369
|
-
log.warn(`[rls-settings] Failed to load RLS settings: ${e.message}`);
|
|
370
|
-
return undefined;
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
const queryRlsModuleLegacy = async (pool, apiId) => {
|
|
374
|
-
const result = await pool.query(RLS_MODULE_SQL, [apiId]);
|
|
375
|
-
return toRlsModule(result.rows[0] ?? null);
|
|
376
|
-
};
|
|
377
|
-
const queryRlsModule = async (pool, databaseId, apiId) => {
|
|
378
|
-
const fromSettings = await queryRlsSettings(pool, databaseId);
|
|
379
|
-
if (fromSettings)
|
|
380
|
-
return fromSettings;
|
|
381
|
-
return queryRlsModuleLegacy(pool, apiId);
|
|
382
|
-
};
|
|
383
|
-
// -- CORS --
|
|
384
|
-
const queryCorsSettings = async (pool, databaseId, apiId) => {
|
|
385
|
-
try {
|
|
386
|
-
if (apiId) {
|
|
387
|
-
const perApi = await pool.query(CORS_SETTINGS_SQL, [databaseId, apiId]);
|
|
388
|
-
if (perApi.rows[0])
|
|
389
|
-
return perApi.rows[0].allowed_origins;
|
|
390
|
-
}
|
|
391
|
-
const dbDefault = await pool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
|
|
392
|
-
return dbDefault.rows[0]?.allowed_origins;
|
|
393
|
-
}
|
|
394
|
-
catch (e) {
|
|
395
|
-
log.warn(`[cors-settings] Failed to load CORS settings: ${e.message}`);
|
|
396
|
-
return undefined;
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
const queryCorsModuleLegacy = async (pool, apiId) => {
|
|
400
|
-
const result = await pool.query(CORS_MODULE_SQL, [apiId]);
|
|
401
|
-
return result.rows[0]?.data?.urls;
|
|
402
|
-
};
|
|
403
|
-
const queryCorsOrigins = async (pool, databaseId, apiId) => {
|
|
404
|
-
const fromSettings = await queryCorsSettings(pool, databaseId, apiId);
|
|
405
|
-
if (fromSettings)
|
|
406
|
-
return fromSettings;
|
|
407
|
-
if (apiId)
|
|
408
|
-
return queryCorsModuleLegacy(pool, apiId);
|
|
409
|
-
return undefined;
|
|
410
|
-
};
|
|
411
|
-
// -- Pubkey --
|
|
412
|
-
const toPubkeyChallengeSettings = (row) => {
|
|
413
|
-
if (!row?.schema || !row?.sign_up_with_key)
|
|
414
|
-
return undefined;
|
|
415
|
-
return {
|
|
416
|
-
schema: row.schema,
|
|
417
|
-
cryptoNetwork: row.crypto_network,
|
|
418
|
-
signUpWithKey: row.sign_up_with_key,
|
|
419
|
-
signInRequestChallenge: row.sign_in_request_challenge,
|
|
420
|
-
signInRecordFailure: row.sign_in_record_failure,
|
|
421
|
-
signInWithChallenge: row.sign_in_with_challenge,
|
|
422
|
-
};
|
|
423
|
-
};
|
|
424
|
-
const toPubkeyChallengeFromModule = (row) => {
|
|
425
|
-
if (!row?.data?.schema)
|
|
426
|
-
return undefined;
|
|
427
|
-
const d = row.data;
|
|
428
|
-
return {
|
|
429
|
-
schema: d.schema,
|
|
430
|
-
cryptoNetwork: d.crypto_network,
|
|
431
|
-
signUpWithKey: d.sign_up_with_key,
|
|
432
|
-
signInRequestChallenge: d.sign_in_request_challenge,
|
|
433
|
-
signInRecordFailure: d.sign_in_record_failure,
|
|
434
|
-
signInWithChallenge: d.sign_in_with_challenge,
|
|
435
|
-
};
|
|
436
|
-
};
|
|
437
|
-
const queryPubkeySettings = async (pool, databaseId) => {
|
|
438
|
-
try {
|
|
439
|
-
const result = await pool.query(PUBKEY_SETTINGS_SQL, [databaseId]);
|
|
440
|
-
return toPubkeyChallengeSettings(result.rows[0] ?? null);
|
|
441
|
-
}
|
|
442
|
-
catch (e) {
|
|
443
|
-
log.warn(`[pubkey-settings] Failed to load pubkey challenge settings: ${e.message}`);
|
|
444
|
-
return undefined;
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
const queryPubkeyModuleLegacy = async (pool, apiId) => {
|
|
448
|
-
const result = await pool.query(PUBKEY_MODULE_SQL, [apiId]);
|
|
449
|
-
return toPubkeyChallengeFromModule(result.rows[0] ?? null);
|
|
450
|
-
};
|
|
451
|
-
const queryPubkeyChallenge = async (pool, databaseId, apiId) => {
|
|
452
|
-
const fromSettings = await queryPubkeySettings(pool, databaseId);
|
|
453
|
-
if (fromSettings)
|
|
454
|
-
return fromSettings;
|
|
455
|
-
if (apiId)
|
|
456
|
-
return queryPubkeyModuleLegacy(pool, apiId);
|
|
457
|
-
return undefined;
|
|
458
|
-
};
|
|
459
|
-
// -- WebAuthn --
|
|
460
|
-
const toWebauthnSettings = (row) => {
|
|
461
|
-
if (!row?.schema)
|
|
462
|
-
return undefined;
|
|
463
|
-
return {
|
|
464
|
-
schema: row.schema,
|
|
465
|
-
credentialsSchema: row.credentials_schema,
|
|
466
|
-
sessionsSchema: row.sessions_schema,
|
|
467
|
-
sessionSecretsSchema: row.session_secrets_schema,
|
|
468
|
-
rpId: row.rp_id,
|
|
469
|
-
rpName: row.rp_name,
|
|
470
|
-
originAllowlist: row.origin_allowlist,
|
|
471
|
-
attestationType: row.attestation_type,
|
|
472
|
-
requireUserVerification: row.require_user_verification,
|
|
473
|
-
residentKey: row.resident_key,
|
|
474
|
-
challengeExpirySeconds: row.challenge_expiry_seconds,
|
|
475
|
-
};
|
|
476
|
-
};
|
|
477
|
-
const queryWebauthnSettings = async (pool, databaseId) => {
|
|
478
|
-
try {
|
|
479
|
-
const result = await pool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]);
|
|
480
|
-
return toWebauthnSettings(result.rows[0] ?? null);
|
|
481
|
-
}
|
|
482
|
-
catch (e) {
|
|
483
|
-
log.warn(`[webauthn-settings] Failed to load webauthn settings: ${e.message}`);
|
|
484
|
-
return undefined;
|
|
485
|
-
}
|
|
486
|
-
};
|
|
487
|
-
// -- Database Settings (feature flags) --
|
|
488
|
-
const toDatabaseSettings = (row) => {
|
|
489
|
-
if (!row)
|
|
490
|
-
return undefined;
|
|
491
|
-
return {
|
|
492
|
-
enableAggregates: row.resolved_enable_aggregates,
|
|
493
|
-
enablePostgis: row.resolved_enable_postgis,
|
|
494
|
-
enableSearch: row.resolved_enable_search,
|
|
495
|
-
enableDirectUploads: row.resolved_enable_direct_uploads,
|
|
496
|
-
enablePresignedUploads: row.resolved_enable_presigned_uploads,
|
|
497
|
-
enableManyToMany: row.resolved_enable_many_to_many,
|
|
498
|
-
enableConnectionFilter: row.resolved_enable_connection_filter,
|
|
499
|
-
enableLtree: row.resolved_enable_ltree,
|
|
500
|
-
enableLlm: row.resolved_enable_llm,
|
|
501
|
-
enableRealtime: row.resolved_enable_realtime,
|
|
502
|
-
enableBulk: row.resolved_enable_bulk,
|
|
503
|
-
};
|
|
504
|
-
};
|
|
505
|
-
const queryDatabaseSettings = async (pool, databaseId, apiId) => {
|
|
506
|
-
try {
|
|
507
|
-
const result = await pool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
|
|
508
|
-
return toDatabaseSettings(result.rows[0] ?? null);
|
|
509
|
-
}
|
|
510
|
-
catch (e) {
|
|
511
|
-
log.warn(`[database-settings] Failed to load database settings: ${e.message}`);
|
|
512
|
-
return undefined;
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
/**
|
|
516
|
-
* Load server-relevant auth settings from the tenant DB.
|
|
517
|
-
* Discovers the auth settings table dynamically by joining
|
|
518
|
-
* metaschema_modules_public.sessions_module with metaschema_public.schema
|
|
519
|
-
* (both public schemas). Fails gracefully if modules or table don't exist yet.
|
|
520
|
-
*/
|
|
521
|
-
const queryAuthSettings = async (opts, dbname) => {
|
|
522
|
-
try {
|
|
523
|
-
const tenantPool = (0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname });
|
|
524
|
-
// Discover the auth settings schema + table name from public metaschema tables
|
|
525
|
-
const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
|
|
526
|
-
const resolved = discovery.rows[0];
|
|
527
|
-
if (!resolved) {
|
|
528
|
-
log.debug('[auth-settings] No sessions_module row found in tenant DB');
|
|
529
|
-
return null;
|
|
530
|
-
}
|
|
531
|
-
// Query the discovered auth settings table
|
|
532
|
-
const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
|
|
533
|
-
return result.rows[0] ?? null;
|
|
534
|
-
}
|
|
535
|
-
catch (e) {
|
|
536
|
-
// Table/module may not exist yet if the 2FA migration hasn't been applied
|
|
537
|
-
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
|
|
538
|
-
return null;
|
|
539
|
-
}
|
|
540
|
-
};
|
|
541
196
|
// =============================================================================
|
|
542
197
|
// Resolution Logic
|
|
543
198
|
// =============================================================================
|
|
@@ -588,16 +243,10 @@ const resolveApiNameHeader = async (ctx) => {
|
|
|
588
243
|
log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
|
|
589
244
|
return null;
|
|
590
245
|
}
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
queryDatabaseSettings(pool, row.database_id, row.api_id),
|
|
596
|
-
queryPubkeyChallenge(pool, row.database_id, row.api_id),
|
|
597
|
-
queryWebauthnSettings(pool, row.database_id),
|
|
598
|
-
]);
|
|
599
|
-
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
|
|
600
|
-
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
|
|
246
|
+
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
247
|
+
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
248
|
+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
249
|
+
return toApiStructure(row, opts, settings);
|
|
601
250
|
};
|
|
602
251
|
const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
603
252
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
@@ -611,16 +260,10 @@ const resolveDomainLookup = async (ctx) => {
|
|
|
611
260
|
log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
|
|
612
261
|
return null;
|
|
613
262
|
}
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
queryDatabaseSettings(pool, row.database_id, row.api_id),
|
|
619
|
-
queryPubkeyChallenge(pool, row.database_id, row.api_id),
|
|
620
|
-
queryWebauthnSettings(pool, row.database_id),
|
|
621
|
-
]);
|
|
622
|
-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
|
|
623
|
-
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
|
|
263
|
+
const loaderCtx = buildLoaderContext(pool, opts, row);
|
|
264
|
+
const settings = await resolveModuleSettings(defaultRegistry, loaderCtx);
|
|
265
|
+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${settings.rlsModule ? 'found' : 'none'}, authSettings: ${settings.authSettings ? 'found' : 'none'}`);
|
|
266
|
+
return toApiStructure(row, opts, settings);
|
|
624
267
|
};
|
|
625
268
|
const buildDevFallbackError = async (ctx, req) => {
|
|
626
269
|
if ((0, env_1.getNodeEnv)() !== 'development')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "4.34.
|
|
3
|
+
"version": "4.34.1",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@agentic-kit/ollama": "^2.0.0",
|
|
45
45
|
"@constructive-io/csrf": "^0.14.0",
|
|
46
|
-
"@constructive-io/express-context": "^0.2.
|
|
46
|
+
"@constructive-io/express-context": "^0.2.1",
|
|
47
47
|
"@constructive-io/graphql-env": "^3.12.0",
|
|
48
48
|
"@constructive-io/graphql-types": "^3.11.0",
|
|
49
49
|
"@constructive-io/s3-utils": "^2.17.1",
|
|
@@ -95,5 +95,5 @@
|
|
|
95
95
|
"nodemon": "^3.1.14",
|
|
96
96
|
"ts-node": "^10.9.2"
|
|
97
97
|
},
|
|
98
|
-
"gitHead": "
|
|
98
|
+
"gitHead": "ccc303b2a2d1d9701648efc124ea1d9f2714711f"
|
|
99
99
|
}
|
package/types.d.ts
CHANGED
|
@@ -1,129 +1,6 @@
|
|
|
1
1
|
import type { PgpmOptions } from '@pgpmjs/types';
|
|
2
2
|
import type { ApiOptions as ApiConfig } from '@constructive-io/graphql-types';
|
|
3
|
-
export
|
|
4
|
-
urls: string[];
|
|
5
|
-
}
|
|
6
|
-
export interface PublicKeyChallengeData {
|
|
7
|
-
schema: string;
|
|
8
|
-
crypto_network: string;
|
|
9
|
-
sign_up_with_key: string;
|
|
10
|
-
sign_in_request_challenge: string;
|
|
11
|
-
sign_in_record_failure: string;
|
|
12
|
-
sign_in_with_challenge: string;
|
|
13
|
-
}
|
|
14
|
-
export interface GenericModuleData {
|
|
15
|
-
[key: string]: unknown;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Resolved feature flags from database_settings + api_settings cascade.
|
|
19
|
-
* api_settings values (when non-null) override database_settings defaults.
|
|
20
|
-
*/
|
|
21
|
-
export interface DatabaseSettings {
|
|
22
|
-
enableAggregates: boolean;
|
|
23
|
-
enablePostgis: boolean;
|
|
24
|
-
enableSearch: boolean;
|
|
25
|
-
enableDirectUploads: boolean;
|
|
26
|
-
enablePresignedUploads: boolean;
|
|
27
|
-
enableManyToMany: boolean;
|
|
28
|
-
enableConnectionFilter: boolean;
|
|
29
|
-
enableLtree: boolean;
|
|
30
|
-
enableLlm: boolean;
|
|
31
|
-
enableRealtime: boolean;
|
|
32
|
-
enableBulk: boolean;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Resolved pubkey challenge config from pubkey_settings typed table.
|
|
36
|
-
* Matches the shape expected by the PublicKeySignature Graphile plugin.
|
|
37
|
-
*/
|
|
38
|
-
export interface PubkeyChallengeSettings {
|
|
39
|
-
schema: string;
|
|
40
|
-
cryptoNetwork: string;
|
|
41
|
-
signUpWithKey: string;
|
|
42
|
-
signInRequestChallenge: string;
|
|
43
|
-
signInRecordFailure: string;
|
|
44
|
-
signInWithChallenge: string;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Resolved WebAuthn config from webauthn_settings typed table.
|
|
48
|
-
* Stored on ApiStructure for future server-side WebAuthn wiring.
|
|
49
|
-
*/
|
|
50
|
-
export interface WebauthnSettings {
|
|
51
|
-
schema: string;
|
|
52
|
-
credentialsSchema: string;
|
|
53
|
-
sessionsSchema: string;
|
|
54
|
-
sessionSecretsSchema: string;
|
|
55
|
-
rpId: string;
|
|
56
|
-
rpName: string;
|
|
57
|
-
originAllowlist: string[];
|
|
58
|
-
attestationType: string;
|
|
59
|
-
requireUserVerification: boolean;
|
|
60
|
-
residentKey: string;
|
|
61
|
-
challengeExpirySeconds: number;
|
|
62
|
-
}
|
|
63
|
-
export type ApiModule = {
|
|
64
|
-
name: 'cors';
|
|
65
|
-
data: CorsModuleData;
|
|
66
|
-
} | {
|
|
67
|
-
name: 'pubkey_challenge';
|
|
68
|
-
data: PublicKeyChallengeData;
|
|
69
|
-
} | {
|
|
70
|
-
name: string;
|
|
71
|
-
data?: GenericModuleData;
|
|
72
|
-
};
|
|
73
|
-
export interface RlsModule {
|
|
74
|
-
authenticate: string;
|
|
75
|
-
authenticateStrict: string;
|
|
76
|
-
privateSchema: {
|
|
77
|
-
schemaName: string;
|
|
78
|
-
};
|
|
79
|
-
publicSchema: {
|
|
80
|
-
schemaName: string;
|
|
81
|
-
};
|
|
82
|
-
currentRole: string;
|
|
83
|
-
currentRoleId: string;
|
|
84
|
-
currentIpAddress: string;
|
|
85
|
-
currentUserAgent: string;
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Server-visible subset of app_auth_settings (lives in the tenant DB private schema).
|
|
89
|
-
* Discovered dynamically via metaschema_modules_public.sessions_module.
|
|
90
|
-
* Loaded once per API resolution and cached alongside the ApiStructure.
|
|
91
|
-
*/
|
|
92
|
-
export interface AuthSettings {
|
|
93
|
-
/** Cookie configuration */
|
|
94
|
-
cookieSecure?: boolean;
|
|
95
|
-
cookieSamesite?: string;
|
|
96
|
-
cookieDomain?: string | null;
|
|
97
|
-
cookieHttponly?: boolean;
|
|
98
|
-
cookieMaxAge?: string | null;
|
|
99
|
-
cookiePath?: string;
|
|
100
|
-
/** Remember me duration (seconds) for extended session cookies */
|
|
101
|
-
rememberMeDuration?: string | null;
|
|
102
|
-
/** reCAPTCHA / CAPTCHA */
|
|
103
|
-
enableCaptcha?: boolean;
|
|
104
|
-
captchaSiteKey?: string | null;
|
|
105
|
-
}
|
|
106
|
-
export interface ApiStructure {
|
|
107
|
-
apiId?: string;
|
|
108
|
-
dbname: string;
|
|
109
|
-
anonRole: string;
|
|
110
|
-
roleName: string;
|
|
111
|
-
schema: string[];
|
|
112
|
-
apiModules: ApiModule[];
|
|
113
|
-
rlsModule?: RlsModule;
|
|
114
|
-
domains?: string[];
|
|
115
|
-
databaseId?: string;
|
|
116
|
-
isPublic?: boolean;
|
|
117
|
-
authSettings?: AuthSettings;
|
|
118
|
-
corsOrigins?: string[];
|
|
119
|
-
databaseSettings?: DatabaseSettings;
|
|
120
|
-
pubkeyChallengeSettings?: PubkeyChallengeSettings;
|
|
121
|
-
webauthnSettings?: WebauthnSettings;
|
|
122
|
-
}
|
|
123
|
-
export type ApiError = {
|
|
124
|
-
errorHtml: string;
|
|
125
|
-
};
|
|
126
|
-
export type ApiConfigResult = ApiStructure | ApiError | null;
|
|
3
|
+
export type { ApiConfigResult, ApiError, ApiModule, ApiStructure, AuthSettings, CorsModuleData, DatabaseSettings, GenericModuleData, PubkeyChallengeSettings, PublicKeyChallengeData, RlsModule, WebauthnSettings, } from '@constructive-io/express-context';
|
|
127
4
|
export type ApiOptions = PgpmOptions & {
|
|
128
5
|
api?: ApiConfig;
|
|
129
6
|
};
|