diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index a7779621b..5ff8ed26c 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -44,6 +44,9 @@ import 'graphile-build'; // Main preset export { ConstructivePreset } from './presets/constructive-preset'; +// Optional presets (not included in ConstructivePreset by default) +export { PgAggregatesPreset } from 'graphile-pg-aggregates'; + // Re-export all plugins for convenience export * from './plugins/index'; diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 51a621a33..bbd3cf537 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; -import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types'; +import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, DatabaseSettings, PubkeyChallengeSettings, RlsModule, WebauthnSettings } from '../types'; import './types'; const log = new Logger('api'); @@ -139,6 +139,100 @@ const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => ` LIMIT 1 `; +const CORS_SETTINGS_SQL = ` + SELECT allowed_origins + FROM services_public.cors_settings + WHERE database_id = $1 AND api_id = $2 + LIMIT 1 +`; + +const CORS_SETTINGS_DB_DEFAULT_SQL = ` + SELECT allowed_origins + FROM services_public.cors_settings + WHERE database_id = $1 AND api_id IS NULL + LIMIT 1 +`; + +const CORS_MODULE_SQL = ` + SELECT data + FROM services_public.api_modules + WHERE api_id = $1 AND name = 'cors' + LIMIT 1 +`; + +const PUBKEY_SETTINGS_SQL = ` + SELECT + s.schema_name AS schema, + ps.crypto_network, + sign_up_fn.name AS sign_up_with_key, + sign_in_req_fn.name AS sign_in_request_challenge, + sign_in_fail_fn.name AS sign_in_record_failure, + sign_in_fn.name AS sign_in_with_challenge + FROM services_public.pubkey_settings ps + LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id + LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id + LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id + LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id + LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id + WHERE ps.database_id = $1 + LIMIT 1 +`; + +const PUBKEY_MODULE_SQL = ` + SELECT data + FROM services_public.api_modules + WHERE api_id = $1 AND name = 'pubkey_challenge' + LIMIT 1 +`; + +const WEBAUTHN_SETTINGS_SQL = ` + SELECT + s.schema_name AS schema, + cred_s.schema_name AS credentials_schema, + sess_s.schema_name AS sessions_schema, + sec_s.schema_name AS session_secrets_schema, + ws.rp_id, + ws.rp_name, + ws.origin_allowlist, + ws.attestation_type, + ws.require_user_verification, + ws.resident_key, + ws.challenge_expiry_seconds + FROM services_public.webauthn_settings ws + LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id + LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id + LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id + LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id + WHERE ws.database_id = $1 + LIMIT 1 +`; + +const DATABASE_SETTINGS_SQL = ` + SELECT + ds.enable_aggregates, + ds.enable_postgis, + ds.enable_search, + ds.enable_direct_uploads, + ds.enable_presigned_uploads, + ds.enable_many_to_many, + ds.enable_connection_filter, + ds.enable_ltree, + ds.enable_llm, + COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates, + COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis, + COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search, + COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads, + COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads, + COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many, + COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter, + COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree, + COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm + FROM services_public.database_settings ds + LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2 + WHERE ds.database_id = $1 + LIMIT 1 +`; + // ============================================================================= // Types // ============================================================================= @@ -179,6 +273,60 @@ interface RlsModuleRow { data: RlsModuleData | null; } +interface CorsSettingsRow { + allowed_origins: string[]; +} + +interface CorsModuleRow { + data: { urls: string[] } | null; +} + +interface PubkeySettingsRow { + schema: string; + crypto_network: string; + sign_up_with_key: string; + sign_in_request_challenge: string; + sign_in_record_failure: string; + sign_in_with_challenge: string; +} + +interface PubkeyModuleRow { + data: { + schema: string; + crypto_network: string; + sign_up_with_key: string; + sign_in_request_challenge: string; + sign_in_record_failure: string; + sign_in_with_challenge: string; + } | null; +} + +interface WebauthnSettingsRow { + schema: string; + credentials_schema: string; + sessions_schema: string; + session_secrets_schema: string; + rp_id: string; + rp_name: string; + origin_allowlist: string[]; + attestation_type: string; + require_user_verification: boolean; + resident_key: string; + challenge_expiry_seconds: number; +} + +interface DatabaseSettingsRow { + resolved_enable_aggregates: boolean; + resolved_enable_postgis: boolean; + resolved_enable_search: boolean; + resolved_enable_direct_uploads: boolean; + resolved_enable_presigned_uploads: boolean; + resolved_enable_many_to_many: boolean; + resolved_enable_connection_filter: boolean; + resolved_enable_ltree: boolean; + resolved_enable_llm: boolean; +} + interface ApiListRow { id: string; database_id: string; @@ -304,18 +452,31 @@ const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined = }; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModule?: RlsModule, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({ +interface ResolvedSettings { + rlsModule?: RlsModule; + authSettingsRow?: AuthSettingsRow | null; + corsOrigins?: string[]; + databaseSettings?: DatabaseSettings; + pubkeyChallengeSettings?: PubkeyChallengeSettings; + webauthnSettings?: WebauthnSettings; +} + +const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedSettings = {}): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', roleName: row.role_name || 'authenticated', schema: row.schemas || [], apiModules: [], - rlsModule, + rlsModule: settings.rlsModule, domains: [], databaseId: row.database_id, isPublic: row.is_public, - authSettings: toAuthSettings(authSettingsRow ?? null), + authSettings: toAuthSettings(settings.authSettingsRow ?? null), + corsOrigins: settings.corsOrigins, + databaseSettings: settings.databaseSettings, + pubkeyChallengeSettings: settings.pubkeyChallengeSettings, + webauthnSettings: settings.webauthnSettings, }); const createAdminStructure = ( @@ -390,6 +551,135 @@ const queryRlsModule = async (pool: Pool, databaseId: string, apiId: string): Pr return queryRlsModuleLegacy(pool, apiId); }; +// -- CORS -- + +const queryCorsSettings = async (pool: Pool, databaseId: string, apiId?: string): Promise => { + try { + if (apiId) { + const perApi = await pool.query(CORS_SETTINGS_SQL, [databaseId, apiId]); + if (perApi.rows[0]) return perApi.rows[0].allowed_origins; + } + const dbDefault = await pool.query(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]); + return dbDefault.rows[0]?.allowed_origins; + } catch { + return undefined; + } +}; + +const queryCorsModuleLegacy = async (pool: Pool, apiId: string): Promise => { + const result = await pool.query(CORS_MODULE_SQL, [apiId]); + return result.rows[0]?.data?.urls; +}; + +const queryCorsOrigins = async (pool: Pool, databaseId: string, apiId?: string): Promise => { + const fromSettings = await queryCorsSettings(pool, databaseId, apiId); + if (fromSettings) return fromSettings; + if (apiId) return queryCorsModuleLegacy(pool, apiId); + return undefined; +}; + +// -- Pubkey -- + +const toPubkeyChallengeSettings = (row: PubkeySettingsRow | null): PubkeyChallengeSettings | undefined => { + if (!row?.schema || !row?.sign_up_with_key) return undefined; + return { + schema: row.schema, + cryptoNetwork: row.crypto_network, + signUpWithKey: row.sign_up_with_key, + signInRequestChallenge: row.sign_in_request_challenge, + signInRecordFailure: row.sign_in_record_failure, + signInWithChallenge: row.sign_in_with_challenge, + }; +}; + +const toPubkeyChallengeFromModule = (row: PubkeyModuleRow | null): PubkeyChallengeSettings | undefined => { + if (!row?.data?.schema) return undefined; + const d = row.data; + return { + schema: d.schema, + cryptoNetwork: d.crypto_network, + signUpWithKey: d.sign_up_with_key, + signInRequestChallenge: d.sign_in_request_challenge, + signInRecordFailure: d.sign_in_record_failure, + signInWithChallenge: d.sign_in_with_challenge, + }; +}; + +const queryPubkeySettings = async (pool: Pool, databaseId: string): Promise => { + try { + const result = await pool.query(PUBKEY_SETTINGS_SQL, [databaseId]); + return toPubkeyChallengeSettings(result.rows[0] ?? null); + } catch { + return undefined; + } +}; + +const queryPubkeyModuleLegacy = async (pool: Pool, apiId: string): Promise => { + const result = await pool.query(PUBKEY_MODULE_SQL, [apiId]); + return toPubkeyChallengeFromModule(result.rows[0] ?? null); +}; + +const queryPubkeyChallenge = async (pool: Pool, databaseId: string, apiId?: string): Promise => { + const fromSettings = await queryPubkeySettings(pool, databaseId); + if (fromSettings) return fromSettings; + if (apiId) return queryPubkeyModuleLegacy(pool, apiId); + return undefined; +}; + +// -- WebAuthn -- + +const toWebauthnSettings = (row: WebauthnSettingsRow | null): WebauthnSettings | undefined => { + if (!row?.schema) return undefined; + return { + schema: row.schema, + credentialsSchema: row.credentials_schema, + sessionsSchema: row.sessions_schema, + sessionSecretsSchema: row.session_secrets_schema, + rpId: row.rp_id, + rpName: row.rp_name, + originAllowlist: row.origin_allowlist, + attestationType: row.attestation_type, + requireUserVerification: row.require_user_verification, + residentKey: row.resident_key, + challengeExpirySeconds: row.challenge_expiry_seconds, + }; +}; + +const queryWebauthnSettings = async (pool: Pool, databaseId: string): Promise => { + try { + const result = await pool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]); + return toWebauthnSettings(result.rows[0] ?? null); + } catch { + return undefined; + } +}; + +// -- Database Settings (feature flags) -- + +const toDatabaseSettings = (row: DatabaseSettingsRow | null): DatabaseSettings | undefined => { + if (!row) return undefined; + return { + enableAggregates: row.resolved_enable_aggregates, + enablePostgis: row.resolved_enable_postgis, + enableSearch: row.resolved_enable_search, + enableDirectUploads: row.resolved_enable_direct_uploads, + enablePresignedUploads: row.resolved_enable_presigned_uploads, + enableManyToMany: row.resolved_enable_many_to_many, + enableConnectionFilter: row.resolved_enable_connection_filter, + enableLtree: row.resolved_enable_ltree, + enableLlm: row.resolved_enable_llm, + }; +}; + +const queryDatabaseSettings = async (pool: Pool, databaseId: string, apiId?: string): Promise => { + try { + const result = await pool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]); + return toDatabaseSettings(result.rows[0] ?? null); + } catch { + return undefined; + } +}; + /** * Load server-relevant auth settings from the tenant DB. * Discovers the auth settings table dynamically by joining @@ -479,10 +769,16 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise { // 2) Per-API allowlist sourced from req.api (if available) // createApiMiddleware runs before this in server.ts, so req.api should be set - const api = (req as any).api as { apiModules?: any[]; domains?: string[] } | undefined; + const api = (req as any).api as ApiStructure | undefined; if (api) { + // Typed cors_settings origins (preferred) + const typedOrigins = api.corsOrigins || []; + // Legacy api_modules CORS data (fallback) const corsModules = (api.apiModules || []).filter((m: any) => m.name === 'cors') as { name: 'cors'; data: CorsModuleData }[]; + const legacyOrigins = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], []); const siteUrls = api.domains || []; - const listOfDomains = corsModules.reduce((m, mod) => [...mod.data.urls, ...m], siteUrls); + const listOfDomains = [...typedOrigins, ...legacyOrigins, ...siteUrls]; if (origin && listOfDomains.includes(origin)) { return callback(null, true); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 3b7e6c751..6275d638a 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -6,13 +6,14 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, type GraphileCacheEntry, graphileCache } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; -import { ConstructivePreset, makePgService } from 'graphile-settings'; +import { ConstructivePreset, makePgService, PgAggregatesPreset } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; import './types'; // for Request type import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; import { observeGraphileBuild } from './observability/graphile-build-stats'; +import type { DatabaseSettings } from '../types'; const maskErrorLog = new Logger('graphile:maskError'); @@ -194,15 +195,28 @@ const reqLabel = (req: Request): string => (req.requestId ? `[${req.requestId}]` /** * Build a PostGraphile v5 preset for a tenant. + * + * When `databaseSettings` are available, feature flags control which + * optional plugins are included. Currently only `enable_aggregates` + * adds a preset (PgAggregatesPreset) — all other features are baked + * into ConstructivePreset and are always-on until per-plugin disable + * logic is implemented in a follow-up. */ const buildPreset = ( pool: import('pg').Pool, schemas: string[], anonRole: string, roleName: string, + databaseSettings?: DatabaseSettings, ): GraphileConfig.Preset => { + const presets: GraphileConfig.Preset[] = [ConstructivePreset]; + + if (databaseSettings?.enableAggregates) { + presets.push(PgAggregatesPreset); + } + return { - extends: [ConstructivePreset], + extends: presets, pgServices: [ makePgService({ pool, @@ -356,7 +370,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { const pool = getPgPool(pgConfig); // Create promise and store in in-flight map BEFORE try block - const preset = buildPreset(pool, schema || [], anonRole, roleName); + const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index fdb50f0c0..8a5c61469 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -18,6 +18,53 @@ export interface GenericModuleData { [key: string]: unknown; } +/** + * Resolved feature flags from database_settings + api_settings cascade. + * api_settings values (when non-null) override database_settings defaults. + */ +export interface DatabaseSettings { + enableAggregates: boolean; + enablePostgis: boolean; + enableSearch: boolean; + enableDirectUploads: boolean; + enablePresignedUploads: boolean; + enableManyToMany: boolean; + enableConnectionFilter: boolean; + enableLtree: boolean; + enableLlm: boolean; +} + +/** + * Resolved pubkey challenge config from pubkey_settings typed table. + * Matches the shape expected by the PublicKeySignature Graphile plugin. + */ +export interface PubkeyChallengeSettings { + schema: string; + cryptoNetwork: string; + signUpWithKey: string; + signInRequestChallenge: string; + signInRecordFailure: string; + signInWithChallenge: string; +} + +/** + * Resolved WebAuthn config from webauthn_settings typed table. + * Stored on ApiStructure for future server-side WebAuthn wiring. + */ +export interface WebauthnSettings { + schema: string; + credentialsSchema: string; + sessionsSchema: string; + sessionSecretsSchema: string; + rpId: string; + rpName: string; + originAllowlist: string[]; + attestationType: string; + requireUserVerification: boolean; + residentKey: string; + challengeExpirySeconds: number; +} + export type ApiModule = | { name: 'cors'; data: CorsModuleData } | { name: 'pubkey_challenge'; data: PublicKeyChallengeData } @@ -68,6 +115,10 @@ export interface ApiStructure { databaseId?: string; isPublic?: boolean; authSettings?: AuthSettings; + corsOrigins?: string[]; + databaseSettings?: DatabaseSettings; + pubkeyChallengeSettings?: PubkeyChallengeSettings; + webauthnSettings?: WebauthnSettings; } export type ApiError = { errorHtml: string };