diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 0bd7c91c6..7d241e114 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -2,6 +2,11 @@ import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; import { svcCache } from '@pgpmjs/server-utils'; import { parseUrl } from '@constructive-io/url-domains'; +import { + createDefaultRegistry, + LoaderContext, + LoaderRegistry, +} from '@constructive-io/express-context'; import { NextFunction, Request, Response } from 'express'; import { Pool } from 'pg'; import { getPgPool } from 'pg-cache'; @@ -12,10 +17,15 @@ import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, Data import './types'; const log = new Logger('api'); -const isDev = () => getNodeEnv() === 'development'; // ============================================================================= -// SQL Queries +// Module Loader Registry (replaces inline SQL queries for per-db config) +// ============================================================================= + +const defaultRegistry: LoaderRegistry = createDefaultRegistry(); + +// ============================================================================= +// SQL Queries (API resolution only — module queries now live in loaders) // ============================================================================= const DOMAIN_LOOKUP_SQL = ` @@ -79,166 +89,8 @@ const API_LIST_SQL = ` LIMIT 100 `; -const RLS_MODULE_SQL = ` - SELECT data - FROM services_public.api_modules - WHERE api_id = $1 AND name = 'rls_module' - LIMIT 1 -`; - -const RLS_SETTINGS_SQL = ` - SELECT - auth_schema.schema_name AS authenticate_schema, - role_schema.schema_name AS role_schema, - auth_fn.name AS authenticate, - auth_strict_fn.name AS authenticate_strict, - role_fn.name AS current_role, - role_id_fn.name AS current_role_id, - ua_fn.name AS current_user_agent, - ip_fn.name AS current_ip_address - FROM services_public.rls_settings rs - LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id - LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id - LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id - LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id - LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id - LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id - LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id - LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id - WHERE rs.database_id = $1 - LIMIT 1 -`; - -/** - * Discover auth settings table location via public metaschema tables. - * Joins sessions_module with metaschema_public.schema to resolve - * the schema name + table name without touching private schemas. - */ -const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table AS table_name - FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id - LIMIT 1 -`; - -/** - * Query auth settings from the discovered table. - * Schema and table name are resolved dynamically from metaschema modules. - */ -const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => ` - SELECT - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age, - cookie_path, - remember_me_duration, - enable_captcha, - captcha_site_key - FROM "${schemaName}"."${tableName}" - 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, - ds.enable_bulk, - 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, - COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime, - COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk - 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 +// Types (API resolution only — module types now in express-context) // ============================================================================= interface ApiRow { @@ -251,89 +103,6 @@ interface ApiRow { schemas: string[]; } -interface RlsModuleData { - authenticate: string; - authenticate_strict: string; - authenticate_schema: string; - role_schema: string; - current_role: string; - current_role_id: string; - current_ip_address: string; - current_user_agent: string; -} - -interface AuthSettingsRow { - cookie_secure: boolean; - cookie_samesite: string; - cookie_domain: string | null; - cookie_httponly: boolean; - cookie_max_age: string | null; - cookie_path: string; - remember_me_duration: string | null; - enable_captcha: boolean; - captcha_site_key: string | null; -} - -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; - resolved_enable_realtime: boolean; - resolved_enable_bulk: boolean; -} - interface ApiListRow { id: string; database_id: string; @@ -366,6 +135,69 @@ type ResolutionMode = | 'meta-schema-header' | 'domain-lookup'; +// ============================================================================= +// Module Resolution (via loader registry) +// ============================================================================= + +interface ResolvedModuleSettings { + rlsModule?: RlsModule; + authSettings?: AuthSettings; + corsOrigins?: string[]; + databaseSettings?: DatabaseSettings; + pubkeyChallengeSettings?: PubkeyChallengeSettings; + webauthnSettings?: WebauthnSettings; +} + +/** + * Build a LoaderContext from the API row and options. + * This is used to resolve per-database module settings via the loader registry. + */ +const buildLoaderContext = ( + servicesPool: Pool, + opts: ApiOptions, + row: ApiRow, +): LoaderContext => ({ + servicesPool, + tenantPool: getPgPool({ ...opts.pg, database: row.dbname }), + databaseId: row.database_id, + apiId: row.api_id, + dbname: row.dbname, +}); + +/** + * Resolve all per-database module settings in parallel via the loader registry. + * Each loader independently caches by databaseId — repeated calls are cheap. + */ +const resolveModuleSettings = async ( + registry: LoaderRegistry, + ctx: LoaderContext, +): Promise => { + const [ + rlsModule, + authSettings, + corsOrigins, + databaseSettings, + pubkeyChallengeSettings, + webauthnSettings, + ] = await Promise.all([ + registry.resolve('rlsModule', ctx), + registry.resolve('authSettings', ctx), + registry.resolve('corsOrigins', ctx), + registry.resolve('databaseSettings', ctx), + registry.resolve('pubkeyChallengeSettings', ctx), + registry.resolve('webauthnSettings', ctx), + ]); + + return { + rlsModule, + authSettings, + corsOrigins, + databaseSettings, + pubkeyChallengeSettings, + webauthnSettings, + }; +}; + // ============================================================================= // Helpers // ============================================================================= @@ -408,72 +240,7 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { return baseKey; }; -const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { - if (!row?.data) return undefined; - const d = row.data; - return { - authenticate: d.authenticate, - authenticateStrict: d.authenticate_strict, - privateSchema: { - schemaName: d.authenticate_schema, - }, - publicSchema: { - schemaName: d.role_schema, - }, - currentRole: d.current_role, - currentRoleId: d.current_role_id, - currentIpAddress: d.current_ip_address, - currentUserAgent: d.current_user_agent, - }; -}; - -const toRlsModuleFromSettings = (row: RlsModuleData | null): RlsModule | undefined => { - if (!row) return undefined; - // If metaschema_public.function rows are missing (e.g. trigger was skipped - // during migration), the LEFT JOINs resolve NULL. Return undefined so the - // caller falls back to the legacy api_modules lookup. - if (!row.authenticate || !row.authenticate_schema) return undefined; - return { - authenticate: row.authenticate, - authenticateStrict: row.authenticate_strict, - privateSchema: { - schemaName: row.authenticate_schema, - }, - publicSchema: { - schemaName: row.role_schema, - }, - currentRole: row.current_role, - currentRoleId: row.current_role_id, - currentIpAddress: row.current_ip_address, - currentUserAgent: row.current_user_agent, - }; -}; - -const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => { - if (!row) return undefined; - return { - cookieSecure: row.cookie_secure, - cookieSamesite: row.cookie_samesite, - cookieDomain: row.cookie_domain, - cookieHttponly: row.cookie_httponly, - cookieMaxAge: row.cookie_max_age, - cookiePath: row.cookie_path, - rememberMeDuration: row.remember_me_duration, - enableCaptcha: row.enable_captcha, - captchaSiteKey: row.captcha_site_key, - }; -}; - -interface ResolvedSettings { - rlsModule?: RlsModule; - authSettingsRow?: AuthSettingsRow | null; - corsOrigins?: string[]; - databaseSettings?: DatabaseSettings; - pubkeyChallengeSettings?: PubkeyChallengeSettings; - webauthnSettings?: WebauthnSettings; -} - -const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedSettings = {}): ApiStructure => ({ +const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedModuleSettings = {}): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', @@ -484,7 +251,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, settings: ResolvedSetting domains: [], databaseId: row.database_id, isPublic: row.is_public, - authSettings: toAuthSettings(settings.authSettingsRow ?? null), + authSettings: settings.authSettings, corsOrigins: settings.corsOrigins, databaseSettings: settings.databaseSettings, pubkeyChallengeSettings: settings.pubkeyChallengeSettings, @@ -507,7 +274,7 @@ const createAdminStructure = ( }); // ============================================================================= -// Database Queries +// Database Queries (API resolution only) // ============================================================================= const validateSchemata = async (pool: Pool, schemas: string[]): Promise => { @@ -543,193 +310,6 @@ const queryApiList = async (pool: Pool, isPublic: boolean): Promise => { - try { - const result = await pool.query(RLS_SETTINGS_SQL, [databaseId]); - return toRlsModuleFromSettings(result.rows[0] ?? null); - } catch (e: any) { - log.warn(`[rls-settings] Failed to load RLS settings: ${e.message}`); - return undefined; - } -}; - -const queryRlsModuleLegacy = async (pool: Pool, apiId: string): Promise => { - const result = await pool.query(RLS_MODULE_SQL, [apiId]); - return toRlsModule(result.rows[0] ?? null); -}; - -const queryRlsModule = async (pool: Pool, databaseId: string, apiId: string): Promise => { - const fromSettings = await queryRlsSettings(pool, databaseId); - if (fromSettings) return fromSettings; - 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 (e: any) { - log.warn(`[cors-settings] Failed to load CORS settings: ${e.message}`); - 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 (e: any) { - log.warn(`[pubkey-settings] Failed to load pubkey challenge settings: ${e.message}`); - 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 (e: any) { - log.warn(`[webauthn-settings] Failed to load webauthn settings: ${e.message}`); - 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, - enableRealtime: row.resolved_enable_realtime, - enableBulk: row.resolved_enable_bulk, - }; -}; - -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 (e: any) { - log.warn(`[database-settings] Failed to load database settings: ${e.message}`); - return undefined; - } -}; - -/** - * Load server-relevant auth settings from the tenant DB. - * Discovers the auth settings table dynamically by joining - * metaschema_modules_public.sessions_module with metaschema_public.schema - * (both public schemas). Fails gracefully if modules or table don't exist yet. - */ -const queryAuthSettings = async ( - opts: ApiOptions, - dbname: string -): Promise => { - try { - const tenantPool = getPgPool({ ...opts.pg, database: dbname }); - - // Discover the auth settings schema + table name from public metaschema tables - const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_DISCOVERY_SQL); - const resolved = discovery.rows[0]; - if (!resolved) { - log.debug('[auth-settings] No sessions_module row found in tenant DB'); - return null; - } - - // Query the discovered auth settings table - const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name)); - return result.rows[0] ?? null; - } catch (e: any) { - // Table/module may not exist yet if the 2FA migration hasn't been applied - log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`); - return null; - } -}; - // ============================================================================= // Resolution Logic // ============================================================================= @@ -788,16 +368,10 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise + +

+ +

+ + + + + +

+ +Extractable Express middleware for Constructive tenant context — domain resolution, JWT auth, pgSettings, withPgClient, and modular per-database cached lookups. ## Usage ```typescript import { createContextMiddleware, - requestIdMiddleware + requestIdMiddleware, + createDefaultRegistry, } from '@constructive-io/express-context'; +const loaders = createDefaultRegistry(); + const app = express(); app.use(requestIdMiddleware()); app.use(apiMiddleware); // sets req.api app.use(authMiddleware); // sets req.token -app.use(createContextMiddleware()); // builds req.constructive +app.use(createContextMiddleware({ loaders })); // builds req.constructive app.post('/v1/chat', async (req, res) => { - const { withPgClient, pgSettings, userId, databaseId } = req.constructive; + const ctx = req.constructive; + const rls = await ctx.useModule('rlsModule'); // only fires if not cached + const auth = await ctx.useModule('authSettings'); // only fires if not cached + // webauthnSettings loader never fires if nobody asks for it - const result = await withPgClient(async (client) => { + const result = await ctx.withPgClient(async (client) => { return client.query('SELECT current_user_id()'); }); @@ -35,3 +53,37 @@ app.post('/v1/chat', async (req, res) => { - **withPgClient** — Tenant-scoped RLS transaction helper (BEGIN → SET LOCAL → fn → COMMIT) - **requestId middleware** — UUID correlation ID (from X-Request-Id header or generated) - **Context middleware** — Composes all of the above into `req.constructive` +- **Module loaders** — Pluggable per-database cached lookups with lazy on-demand resolution + +## Module Loaders + +Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache for one piece of per-database configuration. Loaders are registered in a `LoaderRegistry` and resolved lazily via `useModule(name)`. + +### Built-in loaders + +| Loader | Source | Description | +|--------|--------|-------------| +| `rlsLoader` | `services_public.rls_settings` | RLS module (authenticate functions, schema refs) | +| `corsLoader` | `services_public.cors_settings` | CORS allowed origins | +| `databaseSettingsLoader` | `services_public.database_settings` | Feature flags (aggregates, search, uploads, etc.) | +| `pubkeyLoader` | `services_public.pubkey_settings` | Public key challenge auth settings | +| `webauthnLoader` | `services_public.webauthn_settings` | WebAuthn/passkey configuration | +| `authSettingsLoader` | `metaschema_modules_public.sessions_module` | Cookie/captcha settings (two-step tenant DB discovery) | + +### Custom loaders + +```typescript +import { createModuleLoader, createLoaderRegistry } from '@constructive-io/express-context'; + +const myLoader = createModuleLoader({ + name: 'myModule', + ttlMs: 60_000, + async resolve(ctx) { + const { rows } = await ctx.tenantPool.query(MY_SQL, [ctx.databaseId]); + return rows[0] ? transform(rows[0]) : undefined; + }, +}); + +const registry = createLoaderRegistry(); +registry.register(myLoader); +``` diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index 379055cad..db2b55a82 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -1,7 +1,7 @@ /** * create-loader — Factory for building cached ModuleLoader instances. * - * Wraps a raw resolve function with an LRU cache keyed by databaseId. + * Wraps a raw resolve function with an LRU cache keyed by databaseId:apiId. * Each loader gets its own independent cache with configurable TTL and * max entries. */ @@ -38,7 +38,7 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade name: opts.name, async resolve(ctx: LoaderContext): Promise { - const key = ctx.databaseId; + const key = ctx.apiId ? `${ctx.databaseId}:${ctx.apiId}` : ctx.databaseId; if (cache.has(key)) { log.debug(`Cache HIT databaseId=${key}`); @@ -58,8 +58,15 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade invalidate(databaseId?: string): void { if (databaseId) { - cache.delete(databaseId); - log.debug(`Invalidated databaseId=${databaseId}`); + // Clear the plain databaseId key and any composite databaseId:apiId keys + let cleared = 0; + for (const k of cache.keys()) { + if (k === databaseId || k.startsWith(`${databaseId}:`)) { + cache.delete(k); + cleared++; + } + } + log.debug(`Invalidated ${cleared} entries for databaseId=${databaseId}`); } else { cache.clear(); log.debug(`Invalidated all entries (was size=${cache.size})`);