Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/express-context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@pgpmjs/logger": "workspace:^",
"@pgpmjs/server-utils": "workspace:^",
"@pgpmjs/types": "workspace:^",
"lru-cache": "^11.2.7",
"pg": "^8.21.0",
"pg-cache": "workspace:^",
"pg-query-context": "workspace:^"
Expand Down
62 changes: 57 additions & 5 deletions packages/express-context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* - Tenant database pool (via pg-cache)
* - withPgClient (transaction-scoped RLS helper)
* - Convenience fields (userId, databaseId, requestId)
* - useModule (lazy, on-demand per-database module resolution)
*
* The result is a single `req.constructive` object that any downstream
* route handler can use for tenant-scoped database operations.
Expand All @@ -18,20 +19,45 @@ import type { Pool } from 'pg';
import { getPgPool } from 'pg-cache';
import type { PgpmOptions } from '@pgpmjs/types';

import type { LoaderRegistry } from './loaders/registry';
import type { LoaderContext } from './loaders/types';
import { withPgClient as withPgClientFn } from './pg-client';
import { buildPgSettings } from './pg-settings';
import type { ConstructiveContext } from './types';
import type { BuiltinModuleMap, ConstructiveContext } from './types';

export interface ContextMiddlewareOptions {
/** Base PG options for pool creation (host, port, user, password) */
pg?: PgpmOptions['pg'];
/** Module loader registry for per-database cached lookups */
loaders?: LoaderRegistry;
}

/**
* Create a `useModule` function bound to the given loader context.
*
* Calling `useModule('rlsModule')` lazily resolves the RLS loader,
* hitting the DB only on cache miss. The function is a no-op (returns
* undefined) when no registry is configured.
*/
function createUseModule(
registry: LoaderRegistry | undefined,
loaderCtx: LoaderContext | null,
): ConstructiveContext['useModule'] {
return (async <K extends keyof BuiltinModuleMap>(name: K | string) => {
if (!registry || !loaderCtx) return undefined;
return registry.resolve(name as string, loaderCtx);
}) as ConstructiveContext['useModule'];
}

/**
* Build the ConstructiveContext from the current request state.
*
* Requires `req.api` and `req.requestId` to be set by upstream middleware.
* `req.token` is optional (anonymous requests get null).
*
* Module loaders are NOT resolved eagerly. Instead, `ctx.useModule(name)`
* resolves them on demand — only the modules that middleware actually
* needs will fire SQL queries.
*/
export function buildContext(
req: Request,
Expand All @@ -50,21 +76,35 @@ export function buildContext(
clientIp: req.clientIp,
});

const pool: Pool = getPgPool({
const tenantPool: Pool = getPgPool({
...opts.pg,
database: api.dbname,
});

// Build loader context (if registry provided and databaseId known)
let loaderCtx: LoaderContext | null = null;
if (opts.loaders && api.databaseId) {
const servicesPool: Pool = getPgPool(opts.pg);
loaderCtx = {
servicesPool,
tenantPool,
databaseId: api.databaseId,
apiId: api.apiId,
dbname: api.dbname,
};
}

return {
api,
token,
pgSettings,
databaseId: api.databaseId ?? null,
userId: token?.user_id ?? null,
requestId,
pool,
pool: tenantPool,
withPgClient: <T>(fn: (client: any) => Promise<T>) =>
withPgClientFn(pool, pgSettings, fn),
withPgClientFn(tenantPool, pgSettings, fn),
useModule: createUseModule(opts.loaders, loaderCtx),
};
}

Expand All @@ -75,9 +115,21 @@ export function buildContext(
* Mount AFTER the API resolver and auth middleware:
*
* ```typescript
* import { createContextMiddleware, createDefaultRegistry } from '@constructive-io/express-context';
*
* const loaders = createDefaultRegistry();
*
* app.use(apiMiddleware); // sets req.api
* app.use(authMiddleware); // sets req.token
* app.use(contextMiddleware()); // sets req.constructive
* app.use(createContextMiddleware({ loaders }));
*
* // Downstream middleware/routes call useModule on demand:
* app.post('/v1/chat', async (req, res) => {
* 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
* });
* ```
*/
export function createContextMiddleware(
Expand Down
36 changes: 31 additions & 5 deletions packages/express-context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,28 @@
* - withPgClient (tenant-scoped RLS transaction helper)
* - requestId middleware (UUID correlation ID)
* - Context middleware (composes all of the above into req.constructive)
* - Module loaders (pluggable per-database cached lookups)
*
* @example
* ```typescript
* import {
* createContextMiddleware,
* requestIdMiddleware
* requestIdMiddleware,
* createDefaultRegistry,
* } from '@constructive-io/express-context';
*
* const loaders = createDefaultRegistry();
*
* app.use(requestIdMiddleware());
* app.use(apiMiddleware); // sets req.api (your domain resolver)
* app.use(authMiddleware); // sets req.token (your JWT verifier)
* app.use(createContextMiddleware()); // builds req.constructive
* app.use(createContextMiddleware({ loaders })); // builds req.constructive
*
* app.post('/v1/chat', (req, res) => {
* const { withPgClient, pgSettings, userId, databaseId } = req.constructive;
* // Full tenant-scoped database access
* app.post('/v1/chat', async (req, res) => {
* 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
* });
* ```
*/
Expand All @@ -43,6 +49,7 @@ export type {
GenericModuleData,
PublicKeyChallengeData,
PubkeyChallengeSettings,
BuiltinModuleMap,
RlsModule,
WebauthnSettings,
WithPgClient,
Expand All @@ -62,5 +69,24 @@ export { requestIdMiddleware } from './request-id';
export type { ContextMiddlewareOptions } from './context';
export { buildContext, createContextMiddleware } from './context';

// Module loaders
export type {
CreateLoaderOptions,
LoaderContext,
LoaderRegistry,
ModuleLoader,
} from './loaders';
export {
authSettingsLoader,
corsLoader,
createDefaultRegistry,
createLoaderRegistry,
createModuleLoader,
databaseSettingsLoader,
pubkeyLoader,
rlsLoader,
webauthnLoader,
} from './loaders';

// Side-effect: Express type augmentation
import './types';
89 changes: 89 additions & 0 deletions packages/express-context/src/loaders/auth-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Auth Settings Loader (Tier 2 — tenant DB)
*
* Two-step discovery:
* 1. Query metaschema_modules_public.sessions_module to find the
* schema name and table name for auth settings
* 2. Query the discovered <schema>.<table> in the tenant DB
*
* This is the pattern for any module whose config lives in the tenant
* database rather than the services database.
*/

import type { AuthSettings } from '../types';
import type { LoaderContext, ModuleLoader } from './types';
import { createModuleLoader } from './create-loader';

// ─── SQL ────────────────────────────────────────────────────────────────────

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
`;

const buildAuthSettingsQuery = (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
`;

// ─── Row Types ──────────────────────────────────────────────────────────────

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;
}

// ─── Loader ─────────────────────────────────────────────────────────────────

export const authSettingsLoader: ModuleLoader<AuthSettings> = createModuleLoader<AuthSettings>({
name: 'authSettings',
ttlMs: 5 * 60_000,
async resolve(ctx: LoaderContext) {
const { tenantPool } = ctx;

// Step 1: Discover schema + table from sessions_module
const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(
AUTH_SETTINGS_DISCOVERY_SQL,
);
const resolved = discovery.rows[0];
if (!resolved) return undefined;

// Step 2: Query the actual auth settings table
const result = await tenantPool.query<AuthSettingsRow>(
buildAuthSettingsQuery(resolved.schema_name, resolved.table_name),
);
const row = result.rows[0];
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,
};
},
});
73 changes: 73 additions & 0 deletions packages/express-context/src/loaders/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* CORS Origins Loader
*
* Resolves allowed CORS origins for a database+API combination.
* Checks per-API settings first, falls back to database-level default,
* then to the legacy api_modules approach.
*/

import type { LoaderContext, ModuleLoader } from './types';
import { createModuleLoader } from './create-loader';

// ─── SQL ────────────────────────────────────────────────────────────────────

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
`;

// ─── Row Types ──────────────────────────────────────────────────────────────

interface CorsSettingsRow {
allowed_origins: string[];
}

interface CorsModuleRow {
data: { urls: string[] } | null;
}

// ─── Loader ─────────────────────────────────────────────────────────────────

export const corsLoader: ModuleLoader<string[]> = createModuleLoader<string[]>({
name: 'corsOrigins',
ttlMs: 5 * 60_000,
async resolve(ctx: LoaderContext) {
const { servicesPool, databaseId, apiId } = ctx;

// Try per-API cors_settings first
try {
if (apiId) {
const perApi = await servicesPool.query<CorsSettingsRow>(CORS_SETTINGS_SQL, [databaseId, apiId]);
if (perApi.rows[0]) return perApi.rows[0].allowed_origins;
}
const dbDefault = await servicesPool.query<CorsSettingsRow>(CORS_SETTINGS_DB_DEFAULT_SQL, [databaseId]);
if (dbDefault.rows[0]) return dbDefault.rows[0].allowed_origins;
} catch {
// Table may not exist yet
}

// Fall back to legacy api_modules
if (apiId) {
const result = await servicesPool.query<CorsModuleRow>(CORS_MODULE_SQL, [apiId]);
return result.rows[0]?.data?.urls;
}

return undefined;
},
});
Loading
Loading