Skip to content

Commit a9d0191

Browse files
authored
Merge pull request #1091 from constructive-io/feat/phase3-typed-settings-reads
feat: typed table reads for CORS, pubkey, webauthn, database_settings + Graphile feature flag wiring
2 parents eada727 + b4ee8ba commit a9d0191

5 files changed

Lines changed: 394 additions & 19 deletions

File tree

graphile/graphile-settings/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ import 'graphile-build';
4444
// Main preset
4545
export { ConstructivePreset } from './presets/constructive-preset';
4646

47+
// Optional presets (not included in ConstructivePreset by default)
48+
export { PgAggregatesPreset } from 'graphile-pg-aggregates';
49+
4750
// Re-export all plugins for convenience
4851
export * from './plugins/index';
4952

graphql/server/src/middleware/api.ts

Lines changed: 314 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache';
88

99
import errorPage50x from '../errors/50x';
1010
import errorPage404Message from '../errors/404-message';
11-
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types';
11+
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, DatabaseSettings, PubkeyChallengeSettings, RlsModule, WebauthnSettings } from '../types';
1212
import './types';
1313

1414
const log = new Logger('api');
@@ -139,6 +139,100 @@ const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => `
139139
LIMIT 1
140140
`;
141141

142+
const CORS_SETTINGS_SQL = `
143+
SELECT allowed_origins
144+
FROM services_public.cors_settings
145+
WHERE database_id = $1 AND api_id = $2
146+
LIMIT 1
147+
`;
148+
149+
const CORS_SETTINGS_DB_DEFAULT_SQL = `
150+
SELECT allowed_origins
151+
FROM services_public.cors_settings
152+
WHERE database_id = $1 AND api_id IS NULL
153+
LIMIT 1
154+
`;
155+
156+
const CORS_MODULE_SQL = `
157+
SELECT data
158+
FROM services_public.api_modules
159+
WHERE api_id = $1 AND name = 'cors'
160+
LIMIT 1
161+
`;
162+
163+
const PUBKEY_SETTINGS_SQL = `
164+
SELECT
165+
s.schema_name AS schema,
166+
ps.crypto_network,
167+
sign_up_fn.name AS sign_up_with_key,
168+
sign_in_req_fn.name AS sign_in_request_challenge,
169+
sign_in_fail_fn.name AS sign_in_record_failure,
170+
sign_in_fn.name AS sign_in_with_challenge
171+
FROM services_public.pubkey_settings ps
172+
LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
173+
LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
174+
LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
175+
LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
176+
LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
177+
WHERE ps.database_id = $1
178+
LIMIT 1
179+
`;
180+
181+
const PUBKEY_MODULE_SQL = `
182+
SELECT data
183+
FROM services_public.api_modules
184+
WHERE api_id = $1 AND name = 'pubkey_challenge'
185+
LIMIT 1
186+
`;
187+
188+
const WEBAUTHN_SETTINGS_SQL = `
189+
SELECT
190+
s.schema_name AS schema,
191+
cred_s.schema_name AS credentials_schema,
192+
sess_s.schema_name AS sessions_schema,
193+
sec_s.schema_name AS session_secrets_schema,
194+
ws.rp_id,
195+
ws.rp_name,
196+
ws.origin_allowlist,
197+
ws.attestation_type,
198+
ws.require_user_verification,
199+
ws.resident_key,
200+
ws.challenge_expiry_seconds
201+
FROM services_public.webauthn_settings ws
202+
LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
203+
LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
204+
LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
205+
LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
206+
WHERE ws.database_id = $1
207+
LIMIT 1
208+
`;
209+
210+
const DATABASE_SETTINGS_SQL = `
211+
SELECT
212+
ds.enable_aggregates,
213+
ds.enable_postgis,
214+
ds.enable_search,
215+
ds.enable_direct_uploads,
216+
ds.enable_presigned_uploads,
217+
ds.enable_many_to_many,
218+
ds.enable_connection_filter,
219+
ds.enable_ltree,
220+
ds.enable_llm,
221+
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
222+
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
223+
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
224+
COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
225+
COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
226+
COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
227+
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
228+
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
229+
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm
230+
FROM services_public.database_settings ds
231+
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
232+
WHERE ds.database_id = $1
233+
LIMIT 1
234+
`;
235+
142236
// =============================================================================
143237
// Types
144238
// =============================================================================
@@ -179,6 +273,60 @@ interface RlsModuleRow {
179273
data: RlsModuleData | null;
180274
}
181275

276+
interface CorsSettingsRow {
277+
allowed_origins: string[];
278+
}
279+
280+
interface CorsModuleRow {
281+
data: { urls: string[] } | null;
282+
}
283+
284+
interface PubkeySettingsRow {
285+
schema: string;
286+
crypto_network: string;
287+
sign_up_with_key: string;
288+
sign_in_request_challenge: string;
289+
sign_in_record_failure: string;
290+
sign_in_with_challenge: string;
291+
}
292+
293+
interface PubkeyModuleRow {
294+
data: {
295+
schema: string;
296+
crypto_network: string;
297+
sign_up_with_key: string;
298+
sign_in_request_challenge: string;
299+
sign_in_record_failure: string;
300+
sign_in_with_challenge: string;
301+
} | null;
302+
}
303+
304+
interface WebauthnSettingsRow {
305+
schema: string;
306+
credentials_schema: string;
307+
sessions_schema: string;
308+
session_secrets_schema: string;
309+
rp_id: string;
310+
rp_name: string;
311+
origin_allowlist: string[];
312+
attestation_type: string;
313+
require_user_verification: boolean;
314+
resident_key: string;
315+
challenge_expiry_seconds: number;
316+
}
317+
318+
interface DatabaseSettingsRow {
319+
resolved_enable_aggregates: boolean;
320+
resolved_enable_postgis: boolean;
321+
resolved_enable_search: boolean;
322+
resolved_enable_direct_uploads: boolean;
323+
resolved_enable_presigned_uploads: boolean;
324+
resolved_enable_many_to_many: boolean;
325+
resolved_enable_connection_filter: boolean;
326+
resolved_enable_ltree: boolean;
327+
resolved_enable_llm: boolean;
328+
}
329+
182330
interface ApiListRow {
183331
id: string;
184332
database_id: string;
@@ -304,18 +452,31 @@ const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined =
304452
};
305453
};
306454

307-
const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModule?: RlsModule, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({
455+
interface ResolvedSettings {
456+
rlsModule?: RlsModule;
457+
authSettingsRow?: AuthSettingsRow | null;
458+
corsOrigins?: string[];
459+
databaseSettings?: DatabaseSettings;
460+
pubkeyChallengeSettings?: PubkeyChallengeSettings;
461+
webauthnSettings?: WebauthnSettings;
462+
}
463+
464+
const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedSettings = {}): ApiStructure => ({
308465
apiId: row.api_id,
309466
dbname: row.dbname || opts.pg?.database || '',
310467
anonRole: row.anon_role || 'anon',
311468
roleName: row.role_name || 'authenticated',
312469
schema: row.schemas || [],
313470
apiModules: [],
314-
rlsModule,
471+
rlsModule: settings.rlsModule,
315472
domains: [],
316473
databaseId: row.database_id,
317474
isPublic: row.is_public,
318-
authSettings: toAuthSettings(authSettingsRow ?? null),
475+
authSettings: toAuthSettings(settings.authSettingsRow ?? null),
476+
corsOrigins: settings.corsOrigins,
477+
databaseSettings: settings.databaseSettings,
478+
pubkeyChallengeSettings: settings.pubkeyChallengeSettings,
479+
webauthnSettings: settings.webauthnSettings,
319480
});
320481

321482
const createAdminStructure = (
@@ -390,6 +551,135 @@ const queryRlsModule = async (pool: Pool, databaseId: string, apiId: string): Pr
390551
return queryRlsModuleLegacy(pool, apiId);
391552
};
392553

554+
// -- CORS --
555+
556+
const queryCorsSettings = async (pool: Pool, databaseId: string, apiId?: string): Promise<string[] | undefined> => {
557+
try {
558+
if (apiId) {
559+
const perApi = await pool.query<CorsSettingsRow>(CORS_SETTINGS_SQL, [databaseId, apiId]);
560+
if (perApi.rows[0]) return perApi.rows[0].allowed_origins;
561+
}
562+
const dbDefault = await pool.query<CorsSettingsRow>(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
563+
return dbDefault.rows[0]?.allowed_origins;
564+
} catch {
565+
return undefined;
566+
}
567+
};
568+
569+
const queryCorsModuleLegacy = async (pool: Pool, apiId: string): Promise<string[] | undefined> => {
570+
const result = await pool.query<CorsModuleRow>(CORS_MODULE_SQL, [apiId]);
571+
return result.rows[0]?.data?.urls;
572+
};
573+
574+
const queryCorsOrigins = async (pool: Pool, databaseId: string, apiId?: string): Promise<string[] | undefined> => {
575+
const fromSettings = await queryCorsSettings(pool, databaseId, apiId);
576+
if (fromSettings) return fromSettings;
577+
if (apiId) return queryCorsModuleLegacy(pool, apiId);
578+
return undefined;
579+
};
580+
581+
// -- Pubkey --
582+
583+
const toPubkeyChallengeSettings = (row: PubkeySettingsRow | null): PubkeyChallengeSettings | undefined => {
584+
if (!row?.schema || !row?.sign_up_with_key) return undefined;
585+
return {
586+
schema: row.schema,
587+
cryptoNetwork: row.crypto_network,
588+
signUpWithKey: row.sign_up_with_key,
589+
signInRequestChallenge: row.sign_in_request_challenge,
590+
signInRecordFailure: row.sign_in_record_failure,
591+
signInWithChallenge: row.sign_in_with_challenge,
592+
};
593+
};
594+
595+
const toPubkeyChallengeFromModule = (row: PubkeyModuleRow | null): PubkeyChallengeSettings | undefined => {
596+
if (!row?.data?.schema) return undefined;
597+
const d = row.data;
598+
return {
599+
schema: d.schema,
600+
cryptoNetwork: d.crypto_network,
601+
signUpWithKey: d.sign_up_with_key,
602+
signInRequestChallenge: d.sign_in_request_challenge,
603+
signInRecordFailure: d.sign_in_record_failure,
604+
signInWithChallenge: d.sign_in_with_challenge,
605+
};
606+
};
607+
608+
const queryPubkeySettings = async (pool: Pool, databaseId: string): Promise<PubkeyChallengeSettings | undefined> => {
609+
try {
610+
const result = await pool.query<PubkeySettingsRow>(PUBKEY_SETTINGS_SQL, [databaseId]);
611+
return toPubkeyChallengeSettings(result.rows[0] ?? null);
612+
} catch {
613+
return undefined;
614+
}
615+
};
616+
617+
const queryPubkeyModuleLegacy = async (pool: Pool, apiId: string): Promise<PubkeyChallengeSettings | undefined> => {
618+
const result = await pool.query<PubkeyModuleRow>(PUBKEY_MODULE_SQL, [apiId]);
619+
return toPubkeyChallengeFromModule(result.rows[0] ?? null);
620+
};
621+
622+
const queryPubkeyChallenge = async (pool: Pool, databaseId: string, apiId?: string): Promise<PubkeyChallengeSettings | undefined> => {
623+
const fromSettings = await queryPubkeySettings(pool, databaseId);
624+
if (fromSettings) return fromSettings;
625+
if (apiId) return queryPubkeyModuleLegacy(pool, apiId);
626+
return undefined;
627+
};
628+
629+
// -- WebAuthn --
630+
631+
const toWebauthnSettings = (row: WebauthnSettingsRow | null): WebauthnSettings | undefined => {
632+
if (!row?.schema) return undefined;
633+
return {
634+
schema: row.schema,
635+
credentialsSchema: row.credentials_schema,
636+
sessionsSchema: row.sessions_schema,
637+
sessionSecretsSchema: row.session_secrets_schema,
638+
rpId: row.rp_id,
639+
rpName: row.rp_name,
640+
originAllowlist: row.origin_allowlist,
641+
attestationType: row.attestation_type,
642+
requireUserVerification: row.require_user_verification,
643+
residentKey: row.resident_key,
644+
challengeExpirySeconds: row.challenge_expiry_seconds,
645+
};
646+
};
647+
648+
const queryWebauthnSettings = async (pool: Pool, databaseId: string): Promise<WebauthnSettings | undefined> => {
649+
try {
650+
const result = await pool.query<WebauthnSettingsRow>(WEBAUTHN_SETTINGS_SQL, [databaseId]);
651+
return toWebauthnSettings(result.rows[0] ?? null);
652+
} catch {
653+
return undefined;
654+
}
655+
};
656+
657+
// -- Database Settings (feature flags) --
658+
659+
const toDatabaseSettings = (row: DatabaseSettingsRow | null): DatabaseSettings | undefined => {
660+
if (!row) return undefined;
661+
return {
662+
enableAggregates: row.resolved_enable_aggregates,
663+
enablePostgis: row.resolved_enable_postgis,
664+
enableSearch: row.resolved_enable_search,
665+
enableDirectUploads: row.resolved_enable_direct_uploads,
666+
enablePresignedUploads: row.resolved_enable_presigned_uploads,
667+
enableManyToMany: row.resolved_enable_many_to_many,
668+
enableConnectionFilter: row.resolved_enable_connection_filter,
669+
enableLtree: row.resolved_enable_ltree,
670+
enableLlm: row.resolved_enable_llm,
671+
};
672+
};
673+
674+
const queryDatabaseSettings = async (pool: Pool, databaseId: string, apiId?: string): Promise<DatabaseSettings | undefined> => {
675+
try {
676+
const result = await pool.query<DatabaseSettingsRow>(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
677+
return toDatabaseSettings(result.rows[0] ?? null);
678+
} catch {
679+
return undefined;
680+
}
681+
};
682+
393683
/**
394684
* Load server-relevant auth settings from the tenant DB.
395685
* Discovers the auth settings table dynamically by joining
@@ -479,10 +769,16 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise<ApiStructure |
479769
return null;
480770
}
481771

482-
const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
483-
const authSettings = await queryAuthSettings(opts, row.dbname);
484-
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
485-
return toApiStructure(row, opts, rlsModule, authSettings);
772+
const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
773+
queryRlsModule(pool, row.database_id, row.api_id),
774+
queryAuthSettings(opts, row.dbname),
775+
queryCorsOrigins(pool, row.database_id, row.api_id),
776+
queryDatabaseSettings(pool, row.database_id, row.api_id),
777+
queryPubkeyChallenge(pool, row.database_id, row.api_id),
778+
queryWebauthnSettings(pool, row.database_id),
779+
]);
780+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
781+
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
486782
};
487783

488784
const resolveMetaSchemaHeader = (
@@ -505,10 +801,16 @@ const resolveDomainLookup = async (ctx: ResolveContext): Promise<ApiStructure |
505801
return null;
506802
}
507803

508-
const rlsModule = await queryRlsModule(pool, row.database_id, row.api_id);
509-
const authSettings = await queryAuthSettings(opts, row.dbname);
510-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
511-
return toApiStructure(row, opts, rlsModule, authSettings);
804+
const [rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings] = await Promise.all([
805+
queryRlsModule(pool, row.database_id, row.api_id),
806+
queryAuthSettings(opts, row.dbname),
807+
queryCorsOrigins(pool, row.database_id, row.api_id),
808+
queryDatabaseSettings(pool, row.database_id, row.api_id),
809+
queryPubkeyChallenge(pool, row.database_id, row.api_id),
810+
queryWebauthnSettings(pool, row.database_id),
811+
]);
812+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettingsRow ? 'found' : 'none'}`);
813+
return toApiStructure(row, opts, { rlsModule, authSettingsRow, corsOrigins, databaseSettings, pubkeyChallengeSettings, webauthnSettings });
512814
};
513815

514816
const buildDevFallbackError = async (

0 commit comments

Comments
 (0)