@constructive-io/graphql-server 4.26.0 → 4.27.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.
@@ -125,6 +125,93 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
125
125
  FROM "${schemaName}"."${tableName}"
126
126
  LIMIT 1
127
127
  `;
128
+ const CORS_SETTINGS_SQL = `
129
+ SELECT allowed_origins
130
+ FROM services_public.cors_settings
131
+ WHERE database_id = $1 AND api_id = $2
132
+ LIMIT 1
133
+ `;
134
+ const CORS_SETTINGS_DB_DEFAULT_SQL = `
135
+ SELECT allowed_origins
136
+ FROM services_public.cors_settings
137
+ WHERE database_id = $1 AND api_id IS NULL
138
+ LIMIT 1
139
+ `;
140
+ const CORS_MODULE_SQL = `
141
+ SELECT data
142
+ FROM services_public.api_modules
143
+ WHERE api_id = $1 AND name = 'cors'
144
+ LIMIT 1
145
+ `;
146
+ const PUBKEY_SETTINGS_SQL = `
147
+ SELECT
148
+ s.schema_name AS schema,
149
+ ps.crypto_network,
150
+ sign_up_fn.name AS sign_up_with_key,
151
+ sign_in_req_fn.name AS sign_in_request_challenge,
152
+ sign_in_fail_fn.name AS sign_in_record_failure,
153
+ sign_in_fn.name AS sign_in_with_challenge
154
+ FROM services_public.pubkey_settings ps
155
+ LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
156
+ LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
157
+ LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
158
+ LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
159
+ LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
160
+ WHERE ps.database_id = $1
161
+ LIMIT 1
162
+ `;
163
+ const PUBKEY_MODULE_SQL = `
164
+ SELECT data
165
+ FROM services_public.api_modules
166
+ WHERE api_id = $1 AND name = 'pubkey_challenge'
167
+ LIMIT 1
168
+ `;
169
+ const WEBAUTHN_SETTINGS_SQL = `
170
+ SELECT
171
+ s.schema_name AS schema,
172
+ cred_s.schema_name AS credentials_schema,
173
+ sess_s.schema_name AS sessions_schema,
174
+ sec_s.schema_name AS session_secrets_schema,
175
+ ws.rp_id,
176
+ ws.rp_name,
177
+ ws.origin_allowlist,
178
+ ws.attestation_type,
179
+ ws.require_user_verification,
180
+ ws.resident_key,
181
+ ws.challenge_expiry_seconds
182
+ FROM services_public.webauthn_settings ws
183
+ LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
184
+ LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
185
+ LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
186
+ LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
187
+ WHERE ws.database_id = $1
188
+ LIMIT 1
189
+ `;
190
+ const DATABASE_SETTINGS_SQL = `
191
+ SELECT
192
+ ds.enable_aggregates,
193
+ ds.enable_postgis,
194
+ ds.enable_search,
195
+ ds.enable_direct_uploads,
196
+ ds.enable_presigned_uploads,
197
+ ds.enable_many_to_many,
198
+ ds.enable_connection_filter,
199
+ ds.enable_ltree,
200
+ ds.enable_llm,
201
+ COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
202
+ COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
203
+ COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
204
+ COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
205
+ COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
206
+ COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
207
+ COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
208
+ COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
209
+ COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm
210
+ FROM services_public.database_settings ds
211
+ LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
212
+ WHERE ds.database_id = $1
213
+ LIMIT 1
214
+ `;
128
215
  // =============================================================================
129
216
  // Helpers
130
217
  // =============================================================================
@@ -209,18 +296,22 @@ const toAuthSettings = (row) => {
209
296
  captchaSiteKey: row.captcha_site_key,
210
297
  };
211
298
  };
212
- const toApiStructure = (row, opts, rlsModule, authSettingsRow) => ({
299
+ const toApiStructure = (row, opts, settings = {}) => ({
213
300
  apiId: row.api_id,
214
301
  dbname: row.dbname || opts.pg?.database || '',
215
302
  anonRole: row.anon_role || 'anon',
216
303
  roleName: row.role_name || 'authenticated',
217
304
  schema: row.schemas || [],
218
305
  apiModules: [],
219
- rlsModule,
306
+ rlsModule: settings.rlsModule,
220
307
  domains: [],
221
308
  databaseId: row.database_id,
222
309
  isPublic: row.is_public,
223
- authSettings: toAuthSettings(authSettingsRow ?? null),
310
+ authSettings: toAuthSettings(settings.authSettingsRow ?? null),
311
+ corsOrigins: settings.corsOrigins,
312
+ databaseSettings: settings.databaseSettings,
313
+ pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
314
+ webauthnSettings: settings.webauthnSettings,
224
315
  });
225
316
  const createAdminStructure = (opts, schemas, databaseId) => ({
226
317
  dbname: opts.pg?.database ?? '',
@@ -270,6 +361,132 @@ const queryRlsModule = async (pool, databaseId, apiId) => {
270
361
  return fromSettings;
271
362
  return queryRlsModuleLegacy(pool, apiId);
272
363
  };
364
+ // -- CORS --
365
+ const queryCorsSettings = async (pool, databaseId, apiId) => {
366
+ try {
367
+ if (apiId) {
368
+ const perApi = await pool.query(CORS_SETTINGS_SQL, [databaseId, apiId]);
369
+ if (perApi.rows[0])
370
+ return perApi.rows[0].allowed_origins;
371
+ }
372
+ const dbDefault = await pool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
373
+ return dbDefault.rows[0]?.allowed_origins;
374
+ }
375
+ catch {
376
+ return undefined;
377
+ }
378
+ };
379
+ const queryCorsModuleLegacy = async (pool, apiId) => {
380
+ const result = await pool.query(CORS_MODULE_SQL, [apiId]);
381
+ return result.rows[0]?.data?.urls;
382
+ };
383
+ const queryCorsOrigins = async (pool, databaseId, apiId) => {
384
+ const fromSettings = await queryCorsSettings(pool, databaseId, apiId);
385
+ if (fromSettings)
386
+ return fromSettings;
387
+ if (apiId)
388
+ return queryCorsModuleLegacy(pool, apiId);
389
+ return undefined;
390
+ };
391
+ // -- Pubkey --
392
+ const toPubkeyChallengeSettings = (row) => {
393
+ if (!row?.schema || !row?.sign_up_with_key)
394
+ return undefined;
395
+ return {
396
+ schema: row.schema,
397
+ cryptoNetwork: row.crypto_network,
398
+ signUpWithKey: row.sign_up_with_key,
399
+ signInRequestChallenge: row.sign_in_request_challenge,
400
+ signInRecordFailure: row.sign_in_record_failure,
401
+ signInWithChallenge: row.sign_in_with_challenge,
402
+ };
403
+ };
404
+ const toPubkeyChallengeFromModule = (row) => {
405
+ if (!row?.data?.schema)
406
+ return undefined;
407
+ const d = row.data;
408
+ return {
409
+ schema: d.schema,
410
+ cryptoNetwork: d.crypto_network,
411
+ signUpWithKey: d.sign_up_with_key,
412
+ signInRequestChallenge: d.sign_in_request_challenge,
413
+ signInRecordFailure: d.sign_in_record_failure,
414
+ signInWithChallenge: d.sign_in_with_challenge,
415
+ };
416
+ };
417
+ const queryPubkeySettings = async (pool, databaseId) => {
418
+ try {
419
+ const result = await pool.query(PUBKEY_SETTINGS_SQL, [databaseId]);
420
+ return toPubkeyChallengeSettings(result.rows[0] ?? null);
421
+ }
422
+ catch {
423
+ return undefined;
424
+ }
425
+ };
426
+ const queryPubkeyModuleLegacy = async (pool, apiId) => {
427
+ const result = await pool.query(PUBKEY_MODULE_SQL, [apiId]);
428
+ return toPubkeyChallengeFromModule(result.rows[0] ?? null);
429
+ };
430
+ const queryPubkeyChallenge = async (pool, databaseId, apiId) => {
431
+ const fromSettings = await queryPubkeySettings(pool, databaseId);
432
+ if (fromSettings)
433
+ return fromSettings;
434
+ if (apiId)
435
+ return queryPubkeyModuleLegacy(pool, apiId);
436
+ return undefined;
437
+ };
438
+ // -- WebAuthn --
439
+ const toWebauthnSettings = (row) => {
440
+ if (!row?.schema)
441
+ return undefined;
442
+ return {
443
+ schema: row.schema,
444
+ credentialsSchema: row.credentials_schema,
445
+ sessionsSchema: row.sessions_schema,
446
+ sessionSecretsSchema: row.session_secrets_schema,
447
+ rpId: row.rp_id,
448
+ rpName: row.rp_name,
449
+ originAllowlist: row.origin_allowlist,
450
+ attestationType: row.attestation_type,
451
+ requireUserVerification: row.require_user_verification,
452
+ residentKey: row.resident_key,
453
+ challengeExpirySeconds: row.challenge_expiry_seconds,
454
+ };
455
+ };
456
+ const queryWebauthnSettings = async (pool, databaseId) => {
457
+ try {
458
+ const result = await pool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]);
459
+ return toWebauthnSettings(result.rows[0] ?? null);
460
+ }
461
+ catch {
462
+ return undefined;
463
+ }
464
+ };
465
+ // -- Database Settings (feature flags) --
466
+ const toDatabaseSettings = (row) => {
467
+ if (!row)
468
+ return undefined;
469
+ return {
470
+ enableAggregates: row.resolved_enable_aggregates,
471
+ enablePostgis: row.resolved_enable_postgis,
472
+ enableSearch: row.resolved_enable_search,
473
+ enableDirectUploads: row.resolved_enable_direct_uploads,
474
+ enablePresignedUploads: row.resolved_enable_presigned_uploads,
475
+ enableManyToMany: row.resolved_enable_many_to_many,
476
+ enableConnectionFilter: row.resolved_enable_connection_filter,
477
+ enableLtree: row.resolved_enable_ltree,
478
+ enableLlm: row.resolved_enable_llm,
479
+ };
480
+ };
481
+ const queryDatabaseSettings = async (pool, databaseId, apiId) => {
482
+ try {
483
+ const result = await pool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
484
+ return toDatabaseSettings(result.rows[0] ?? null);
485
+ }
486
+ catch {
487
+ return undefined;
488
+ }
489
+ };
273
490
  /**
274
491
  * Load server-relevant auth settings from the tenant DB.
275
492
  * Discovers the auth settings table dynamically by joining
@@ -346,10 +563,16 @@ const resolveApiNameHeader = async (ctx) => {
346
563
  log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
347
564
  return null;
348
565
  }
349
- const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
350
- const authSettings = await queryAuthSettings(opts, row.dbname);
351
- log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
352
- return toApiStructure(row, opts, rlsModule, authSettings);
566
+ const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
567
+ queryRlsModule(pool, row.database_id, row.api_id),
568
+ queryAuthSettings(opts, row.dbname),
569
+ queryCorsOrigins(pool, row.database_id, row.api_id),
570
+ queryDatabaseSettings(pool, row.database_id, row.api_id),
571
+ queryPubkeyChallenge(pool, row.database_id, row.api_id),
572
+ queryWebauthnSettings(pool, row.database_id),
573
+ ]);
574
+ log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
575
+ return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
353
576
  };
354
577
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
355
578
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
@@ -363,10 +586,16 @@ const resolveDomainLookup = async (ctx) => {
363
586
  log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
364
587
  return null;
365
588
  }
366
- const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
367
- const authSettings = await queryAuthSettings(opts, row.dbname);
368
- log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
369
- return toApiStructure(row, opts, rlsModule, authSettings);
589
+ const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
590
+ queryRlsModule(pool, row.database_id, row.api_id),
591
+ queryAuthSettings(opts, row.dbname),
592
+ queryCorsOrigins(pool, row.database_id, row.api_id),
593
+ queryDatabaseSettings(pool, row.database_id, row.api_id),
594
+ queryPubkeyChallenge(pool, row.database_id, row.api_id),
595
+ queryWebauthnSettings(pool, row.database_id),
596
+ ]);
597
+ log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
598
+ return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
370
599
  };
371
600
  const buildDevFallbackError = async (ctx, req) => {
372
601
  if (getNodeEnv() !== 'development')
@@ -6,7 +6,8 @@ import './types'; // for Request type
6
6
  *
7
7
  * Feature parity + compatibility:
8
8
  * - Respects a global fallback origin (e.g. from env/CLI) for quick overrides.
9
- * - Preserves multi-tenant, per-API CORS via meta schema ('cors' module + domains).
9
+ * - Reads per-API CORS origins from typed cors_settings table (via req.api.corsOrigins).
10
+ * - Falls back to legacy api_modules CORS data for backwards compatibility.
10
11
  * - Always allows localhost to ease development.
11
12
  *
12
13
  * Usage:
@@ -31,9 +32,13 @@ export const cors = (fallbackOrigin) => {
31
32
  // createApiMiddleware runs before this in server.ts, so req.api should be set
32
33
  const api = req.api;
33
34
  if (api) {
35
+ // Typed cors_settings origins (preferred)
36
+ const typedOrigins = api.corsOrigins || [];
37
+ // Legacy api_modules CORS data (fallback)
34
38
  const corsModules = (api.apiModules || []).filter((m) => m.name === 'cors');
39
+ const legacyOrigins = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], []);
35
40
  const siteUrls = api.domains || [];
36
- const listOfDomains = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], siteUrls);
41
+ const listOfDomains = [...typedOrigins, ...legacyOrigins, ...siteUrls];
37
42
  if (origin && listOfDomains.includes(origin)) {
38
43
  return callback(null, true);
39
44
  }
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { getNodeEnv } from '@pgpmjs/env';
3
3
  import { Logger } from '@pgpmjs/logger';
4
4
  import { createGraphileInstance, graphileCache } from 'graphile-cache';
5
- import { ConstructivePreset, makePgService } from 'graphile-settings';
5
+ import { createConstructivePreset, makePgService } from 'graphile-settings';
6
6
  import { getPgPool } from 'pg-cache';
7
7
  import { getPgEnvOptions } from 'pg-env';
8
8
  import './types'; // for Request type
@@ -177,10 +177,15 @@ const log = new Logger('graphile');
177
177
  const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
178
178
  /**
179
179
  * Build a PostGraphile v5 preset for a tenant.
180
+ *
181
+ * When `databaseSettings` are available the flags are forwarded to
182
+ * `createConstructivePreset()` which conditionally includes each
183
+ * plugin preset. Without settings the default preset is used
184
+ * (everything on except aggregates and LLM).
180
185
  */
181
- const buildPreset = (pool, schemas, anonRole, roleName) => {
186
+ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
182
187
  return {
183
- extends: [ConstructivePreset],
188
+ extends: [createConstructivePreset(databaseSettings)],
184
189
  pgServices: [
185
190
  makePgService({
186
191
  pool,
@@ -314,7 +319,7 @@ export const graphile = (opts) => {
314
319
  // properly, preventing leaked connections during database teardown.
315
320
  const pool = getPgPool(pgConfig);
316
321
  // Create promise and store in in-flight map BEFORE try block
317
- const preset = buildPreset(pool, schema || [], anonRole, roleName);
322
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings);
318
323
  const creationPromise = observeGraphileBuild({
319
324
  cacheKey: key,
320
325
  serviceKey: key,
package/middleware/api.js CHANGED
@@ -131,6 +131,93 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
131
131
  FROM "${schemaName}"."${tableName}"
132
132
  LIMIT 1
133
133
  `;
134
+ const CORS_SETTINGS_SQL = `
135
+ SELECT allowed_origins
136
+ FROM services_public.cors_settings
137
+ WHERE database_id = $1 AND api_id = $2
138
+ LIMIT 1
139
+ `;
140
+ const CORS_SETTINGS_DB_DEFAULT_SQL = `
141
+ SELECT allowed_origins
142
+ FROM services_public.cors_settings
143
+ WHERE database_id = $1 AND api_id IS NULL
144
+ LIMIT 1
145
+ `;
146
+ const CORS_MODULE_SQL = `
147
+ SELECT data
148
+ FROM services_public.api_modules
149
+ WHERE api_id = $1 AND name = 'cors'
150
+ LIMIT 1
151
+ `;
152
+ const PUBKEY_SETTINGS_SQL = `
153
+ SELECT
154
+ s.schema_name AS schema,
155
+ ps.crypto_network,
156
+ sign_up_fn.name AS sign_up_with_key,
157
+ sign_in_req_fn.name AS sign_in_request_challenge,
158
+ sign_in_fail_fn.name AS sign_in_record_failure,
159
+ sign_in_fn.name AS sign_in_with_challenge
160
+ FROM services_public.pubkey_settings ps
161
+ LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
162
+ LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
163
+ LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
164
+ LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
165
+ LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
166
+ WHERE ps.database_id = $1
167
+ LIMIT 1
168
+ `;
169
+ const PUBKEY_MODULE_SQL = `
170
+ SELECT data
171
+ FROM services_public.api_modules
172
+ WHERE api_id = $1 AND name = 'pubkey_challenge'
173
+ LIMIT 1
174
+ `;
175
+ const WEBAUTHN_SETTINGS_SQL = `
176
+ SELECT
177
+ s.schema_name AS schema,
178
+ cred_s.schema_name AS credentials_schema,
179
+ sess_s.schema_name AS sessions_schema,
180
+ sec_s.schema_name AS session_secrets_schema,
181
+ ws.rp_id,
182
+ ws.rp_name,
183
+ ws.origin_allowlist,
184
+ ws.attestation_type,
185
+ ws.require_user_verification,
186
+ ws.resident_key,
187
+ ws.challenge_expiry_seconds
188
+ FROM services_public.webauthn_settings ws
189
+ LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
190
+ LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
191
+ LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
192
+ LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
193
+ WHERE ws.database_id = $1
194
+ LIMIT 1
195
+ `;
196
+ const DATABASE_SETTINGS_SQL = `
197
+ SELECT
198
+ ds.enable_aggregates,
199
+ ds.enable_postgis,
200
+ ds.enable_search,
201
+ ds.enable_direct_uploads,
202
+ ds.enable_presigned_uploads,
203
+ ds.enable_many_to_many,
204
+ ds.enable_connection_filter,
205
+ ds.enable_ltree,
206
+ ds.enable_llm,
207
+ COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
208
+ COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
209
+ COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
210
+ COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
211
+ COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
212
+ COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
213
+ COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
214
+ COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
215
+ COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm
216
+ FROM services_public.database_settings ds
217
+ LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
218
+ WHERE ds.database_id = $1
219
+ LIMIT 1
220
+ `;
134
221
  // =============================================================================
135
222
  // Helpers
136
223
  // =============================================================================
@@ -217,18 +304,22 @@ const toAuthSettings = (row) => {
217
304
  captchaSiteKey: row.captcha_site_key,
218
305
  };
219
306
  };
220
- const toApiStructure = (row, opts, rlsModule, authSettingsRow) => ({
307
+ const toApiStructure = (row, opts, settings = {}) => ({
221
308
  apiId: row.api_id,
222
309
  dbname: row.dbname || opts.pg?.database || '',
223
310
  anonRole: row.anon_role || 'anon',
224
311
  roleName: row.role_name || 'authenticated',
225
312
  schema: row.schemas || [],
226
313
  apiModules: [],
227
- rlsModule,
314
+ rlsModule: settings.rlsModule,
228
315
  domains: [],
229
316
  databaseId: row.database_id,
230
317
  isPublic: row.is_public,
231
- authSettings: toAuthSettings(authSettingsRow ?? null),
318
+ authSettings: toAuthSettings(settings.authSettingsRow ?? null),
319
+ corsOrigins: settings.corsOrigins,
320
+ databaseSettings: settings.databaseSettings,
321
+ pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
322
+ webauthnSettings: settings.webauthnSettings,
232
323
  });
233
324
  const createAdminStructure = (opts, schemas, databaseId) => ({
234
325
  dbname: opts.pg?.database ?? '',
@@ -278,6 +369,132 @@ const queryRlsModule = async (pool, databaseId, apiId) => {
278
369
  return fromSettings;
279
370
  return queryRlsModuleLegacy(pool, apiId);
280
371
  };
372
+ // -- CORS --
373
+ const queryCorsSettings = async (pool, databaseId, apiId) => {
374
+ try {
375
+ if (apiId) {
376
+ const perApi = await pool.query(CORS_SETTINGS_SQL, [databaseId, apiId]);
377
+ if (perApi.rows[0])
378
+ return perApi.rows[0].allowed_origins;
379
+ }
380
+ const dbDefault = await pool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
381
+ return dbDefault.rows[0]?.allowed_origins;
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ };
387
+ const queryCorsModuleLegacy = async (pool, apiId) => {
388
+ const result = await pool.query(CORS_MODULE_SQL, [apiId]);
389
+ return result.rows[0]?.data?.urls;
390
+ };
391
+ const queryCorsOrigins = async (pool, databaseId, apiId) => {
392
+ const fromSettings = await queryCorsSettings(pool, databaseId, apiId);
393
+ if (fromSettings)
394
+ return fromSettings;
395
+ if (apiId)
396
+ return queryCorsModuleLegacy(pool, apiId);
397
+ return undefined;
398
+ };
399
+ // -- Pubkey --
400
+ const toPubkeyChallengeSettings = (row) => {
401
+ if (!row?.schema || !row?.sign_up_with_key)
402
+ return undefined;
403
+ return {
404
+ schema: row.schema,
405
+ cryptoNetwork: row.crypto_network,
406
+ signUpWithKey: row.sign_up_with_key,
407
+ signInRequestChallenge: row.sign_in_request_challenge,
408
+ signInRecordFailure: row.sign_in_record_failure,
409
+ signInWithChallenge: row.sign_in_with_challenge,
410
+ };
411
+ };
412
+ const toPubkeyChallengeFromModule = (row) => {
413
+ if (!row?.data?.schema)
414
+ return undefined;
415
+ const d = row.data;
416
+ return {
417
+ schema: d.schema,
418
+ cryptoNetwork: d.crypto_network,
419
+ signUpWithKey: d.sign_up_with_key,
420
+ signInRequestChallenge: d.sign_in_request_challenge,
421
+ signInRecordFailure: d.sign_in_record_failure,
422
+ signInWithChallenge: d.sign_in_with_challenge,
423
+ };
424
+ };
425
+ const queryPubkeySettings = async (pool, databaseId) => {
426
+ try {
427
+ const result = await pool.query(PUBKEY_SETTINGS_SQL, [databaseId]);
428
+ return toPubkeyChallengeSettings(result.rows[0] ?? null);
429
+ }
430
+ catch {
431
+ return undefined;
432
+ }
433
+ };
434
+ const queryPubkeyModuleLegacy = async (pool, apiId) => {
435
+ const result = await pool.query(PUBKEY_MODULE_SQL, [apiId]);
436
+ return toPubkeyChallengeFromModule(result.rows[0] ?? null);
437
+ };
438
+ const queryPubkeyChallenge = async (pool, databaseId, apiId) => {
439
+ const fromSettings = await queryPubkeySettings(pool, databaseId);
440
+ if (fromSettings)
441
+ return fromSettings;
442
+ if (apiId)
443
+ return queryPubkeyModuleLegacy(pool, apiId);
444
+ return undefined;
445
+ };
446
+ // -- WebAuthn --
447
+ const toWebauthnSettings = (row) => {
448
+ if (!row?.schema)
449
+ return undefined;
450
+ return {
451
+ schema: row.schema,
452
+ credentialsSchema: row.credentials_schema,
453
+ sessionsSchema: row.sessions_schema,
454
+ sessionSecretsSchema: row.session_secrets_schema,
455
+ rpId: row.rp_id,
456
+ rpName: row.rp_name,
457
+ originAllowlist: row.origin_allowlist,
458
+ attestationType: row.attestation_type,
459
+ requireUserVerification: row.require_user_verification,
460
+ residentKey: row.resident_key,
461
+ challengeExpirySeconds: row.challenge_expiry_seconds,
462
+ };
463
+ };
464
+ const queryWebauthnSettings = async (pool, databaseId) => {
465
+ try {
466
+ const result = await pool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]);
467
+ return toWebauthnSettings(result.rows[0] ?? null);
468
+ }
469
+ catch {
470
+ return undefined;
471
+ }
472
+ };
473
+ // -- Database Settings (feature flags) --
474
+ const toDatabaseSettings = (row) => {
475
+ if (!row)
476
+ return undefined;
477
+ return {
478
+ enableAggregates: row.resolved_enable_aggregates,
479
+ enablePostgis: row.resolved_enable_postgis,
480
+ enableSearch: row.resolved_enable_search,
481
+ enableDirectUploads: row.resolved_enable_direct_uploads,
482
+ enablePresignedUploads: row.resolved_enable_presigned_uploads,
483
+ enableManyToMany: row.resolved_enable_many_to_many,
484
+ enableConnectionFilter: row.resolved_enable_connection_filter,
485
+ enableLtree: row.resolved_enable_ltree,
486
+ enableLlm: row.resolved_enable_llm,
487
+ };
488
+ };
489
+ const queryDatabaseSettings = async (pool, databaseId, apiId) => {
490
+ try {
491
+ const result = await pool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
492
+ return toDatabaseSettings(result.rows[0] ?? null);
493
+ }
494
+ catch {
495
+ return undefined;
496
+ }
497
+ };
281
498
  /**
282
499
  * Load server-relevant auth settings from the tenant DB.
283
500
  * Discovers the auth settings table dynamically by joining
@@ -354,10 +571,16 @@ const resolveApiNameHeader = async (ctx) => {
354
571
  log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
355
572
  return null;
356
573
  }
357
- const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
358
- const authSettings = await queryAuthSettings(opts, row.dbname);
359
- log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
360
- return toApiStructure(row, opts, rlsModule, authSettings);
574
+ const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
575
+ queryRlsModule(pool, row.database_id, row.api_id),
576
+ queryAuthSettings(opts, row.dbname),
577
+ queryCorsOrigins(pool, row.database_id, row.api_id),
578
+ queryDatabaseSettings(pool, row.database_id, row.api_id),
579
+ queryPubkeyChallenge(pool, row.database_id, row.api_id),
580
+ queryWebauthnSettings(pool, row.database_id),
581
+ ]);
582
+ log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
583
+ return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
361
584
  };
362
585
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
363
586
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
@@ -371,10 +594,16 @@ const resolveDomainLookup = async (ctx) => {
371
594
  log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
372
595
  return null;
373
596
  }
374
- const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
375
- const authSettings = await queryAuthSettings(opts, row.dbname);
376
- log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
377
- return toApiStructure(row, opts, rlsModule, authSettings);
597
+ const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
598
+ queryRlsModule(pool, row.database_id, row.api_id),
599
+ queryAuthSettings(opts, row.dbname),
600
+ queryCorsOrigins(pool, row.database_id, row.api_id),
601
+ queryDatabaseSettings(pool, row.database_id, row.api_id),
602
+ queryPubkeyChallenge(pool, row.database_id, row.api_id),
603
+ queryWebauthnSettings(pool, row.database_id),
604
+ ]);
605
+ log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
606
+ return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
378
607
  };
379
608
  const buildDevFallbackError = async (ctx, req) => {
380
609
  if ((0, env_1.getNodeEnv)() !== 'development')
@@ -5,7 +5,8 @@ import './types';
5
5
  *
6
6
  * Feature parity + compatibility:
7
7
  * - Respects a global fallback origin (e.g. from env/CLI) for quick overrides.
8
- * - Preserves multi-tenant, per-API CORS via meta schema ('cors' module + domains).
8
+ * - Reads per-API CORS origins from typed cors_settings table (via req.api.corsOrigins).
9
+ * - Falls back to legacy api_modules CORS data for backwards compatibility.
9
10
  * - Always allows localhost to ease development.
10
11
  *
11
12
  * Usage:
@@ -12,7 +12,8 @@ require("./types"); // for Request type
12
12
  *
13
13
  * Feature parity + compatibility:
14
14
  * - Respects a global fallback origin (e.g. from env/CLI) for quick overrides.
15
- * - Preserves multi-tenant, per-API CORS via meta schema ('cors' module + domains).
15
+ * - Reads per-API CORS origins from typed cors_settings table (via req.api.corsOrigins).
16
+ * - Falls back to legacy api_modules CORS data for backwards compatibility.
16
17
  * - Always allows localhost to ease development.
17
18
  *
18
19
  * Usage:
@@ -37,9 +38,13 @@ const cors = (fallbackOrigin) => {
37
38
  // createApiMiddleware runs before this in server.ts, so req.api should be set
38
39
  const api = req.api;
39
40
  if (api) {
41
+ // Typed cors_settings origins (preferred)
42
+ const typedOrigins = api.corsOrigins || [];
43
+ // Legacy api_modules CORS data (fallback)
40
44
  const corsModules = (api.apiModules || []).filter((m) => m.name === 'cors');
45
+ const legacyOrigins = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], []);
41
46
  const siteUrls = api.domains || [];
42
- const listOfDomains = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], siteUrls);
47
+ const listOfDomains = [...typedOrigins, ...legacyOrigins, ...siteUrls];
43
48
  if (origin && listOfDomains.includes(origin)) {
44
49
  return callback(null, true);
45
50
  }
@@ -186,10 +186,15 @@ const log = new logger_1.Logger('graphile');
186
186
  const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
187
187
  /**
188
188
  * Build a PostGraphile v5 preset for a tenant.
189
+ *
190
+ * When `databaseSettings` are available the flags are forwarded to
191
+ * `createConstructivePreset()` which conditionally includes each
192
+ * plugin preset. Without settings the default preset is used
193
+ * (everything on except aggregates and LLM).
189
194
  */
190
- const buildPreset = (pool, schemas, anonRole, roleName) => {
195
+ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
191
196
  return {
192
- extends: [graphile_settings_1.ConstructivePreset],
197
+ extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
193
198
  pgServices: [
194
199
  (0, graphile_settings_1.makePgService)({
195
200
  pool,
@@ -323,7 +328,7 @@ const graphile = (opts) => {
323
328
  // properly, preventing leaked connections during database teardown.
324
329
  const pool = (0, pg_cache_1.getPgPool)(pgConfig);
325
330
  // Create promise and store in in-flight map BEFORE try block
326
- const preset = buildPreset(pool, schema || [], anonRole, roleName);
331
+ const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings);
327
332
  const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
328
333
  cacheKey: key,
329
334
  serviceKey: key,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.26.0",
3
+ "version": "4.27.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -63,7 +63,7 @@
63
63
  "graphile-build-pg": "5.0.0",
64
64
  "graphile-cache": "^3.8.0",
65
65
  "graphile-config": "1.0.0",
66
- "graphile-settings": "^4.30.1",
66
+ "graphile-settings": "^4.31.0",
67
67
  "graphile-utils": "5.0.0",
68
68
  "graphql": "16.13.0",
69
69
  "graphql-upload": "^13.0.0",
@@ -85,10 +85,10 @@
85
85
  "@types/multer": "^2.1.0",
86
86
  "@types/pg": "^8.18.0",
87
87
  "@types/request-ip": "^0.0.41",
88
- "graphile-test": "4.12.0",
88
+ "graphile-test": "4.12.1",
89
89
  "makage": "^0.3.0",
90
90
  "nodemon": "^3.1.14",
91
91
  "ts-node": "^10.9.2"
92
92
  },
93
- "gitHead": "97ec8e14f2b0855b0ee0bc732a082e1a91301b64"
93
+ "gitHead": "38a99d5e61d756271a0024eb16d4f85923da936f"
94
94
  }
package/types.d.ts CHANGED
@@ -14,6 +14,50 @@ export interface PublicKeyChallengeData {
14
14
  export interface GenericModuleData {
15
15
  [key: string]: unknown;
16
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
+ }
32
+ /**
33
+ * Resolved pubkey challenge config from pubkey_settings typed table.
34
+ * Matches the shape expected by the PublicKeySignature Graphile plugin.
35
+ */
36
+ export interface PubkeyChallengeSettings {
37
+ schema: string;
38
+ cryptoNetwork: string;
39
+ signUpWithKey: string;
40
+ signInRequestChallenge: string;
41
+ signInRecordFailure: string;
42
+ signInWithChallenge: string;
43
+ }
44
+ /**
45
+ * Resolved WebAuthn config from webauthn_settings typed table.
46
+ * Stored on ApiStructure for future server-side WebAuthn wiring.
47
+ */
48
+ export interface WebauthnSettings {
49
+ schema: string;
50
+ credentialsSchema: string;
51
+ sessionsSchema: string;
52
+ sessionSecretsSchema: string;
53
+ rpId: string;
54
+ rpName: string;
55
+ originAllowlist: string[];
56
+ attestationType: string;
57
+ requireUserVerification: boolean;
58
+ residentKey: string;
59
+ challengeExpirySeconds: number;
60
+ }
17
61
  export type ApiModule = {
18
62
  name: 'cors';
19
63
  data: CorsModuleData;
@@ -67,6 +111,10 @@ export interface ApiStructure {
67
111
  databaseId?: string;
68
112
  isPublic?: boolean;
69
113
  authSettings?: AuthSettings;
114
+ corsOrigins?: string[];
115
+ databaseSettings?: DatabaseSettings;
116
+ pubkeyChallengeSettings?: PubkeyChallengeSettings;
117
+ webauthnSettings?: WebauthnSettings;
70
118
  }
71
119
  export type ApiError = {
72
120
  errorHtml: string;