@constructive-io/express-context 0.2.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.
- package/LICENSE +23 -0
- package/README.md +95 -0
- package/context.d.ts +61 -0
- package/context.js +119 -0
- package/esm/context.js +115 -0
- package/esm/index.js +45 -0
- package/esm/loaders/auth-settings.js +62 -0
- package/esm/loaders/cors.js +55 -0
- package/esm/loaders/create-loader.js +53 -0
- package/esm/loaders/database-settings.js +52 -0
- package/esm/loaders/index.js +56 -0
- package/esm/loaders/pubkey.js +69 -0
- package/esm/loaders/registry.js +69 -0
- package/esm/loaders/rls.js +93 -0
- package/esm/loaders/types.js +12 -0
- package/esm/loaders/webauthn.js +54 -0
- package/esm/pg-client.js +8 -0
- package/esm/pg-settings.js +44 -0
- package/esm/request-id.js +19 -0
- package/esm/types.js +9 -0
- package/index.d.ts +46 -0
- package/index.js +62 -0
- package/loaders/auth-settings.d.ts +14 -0
- package/loaders/auth-settings.js +65 -0
- package/loaders/cors.d.ts +9 -0
- package/loaders/cors.js +58 -0
- package/loaders/create-loader.d.ts +19 -0
- package/loaders/create-loader.js +56 -0
- package/loaders/database-settings.d.ts +10 -0
- package/loaders/database-settings.js +55 -0
- package/loaders/index.d.ts +39 -0
- package/loaders/index.js +68 -0
- package/loaders/pubkey.d.ts +10 -0
- package/loaders/pubkey.js +72 -0
- package/loaders/registry.d.ts +37 -0
- package/loaders/registry.js +72 -0
- package/loaders/rls.d.ts +10 -0
- package/loaders/rls.js +96 -0
- package/loaders/types.d.ts +43 -0
- package/loaders/types.js +13 -0
- package/loaders/webauthn.d.ts +9 -0
- package/loaders/webauthn.js +57 -0
- package/package.json +63 -0
- package/pg-client.d.ts +8 -0
- package/pg-client.js +12 -0
- package/pg-settings.d.ts +29 -0
- package/pg-settings.js +47 -0
- package/request-id.d.ts +14 -0
- package/request-id.js +22 -0
- package/types.d.ts +197 -0
- package/types.js +10 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Settings Loader
|
|
3
|
+
*
|
|
4
|
+
* Resolves per-database feature flags (aggregates, postgis, search,
|
|
5
|
+
* uploads, many-to-many, connection filters, ltree, llm, realtime, bulk).
|
|
6
|
+
* Merges database-level defaults with optional per-API overrides.
|
|
7
|
+
*/
|
|
8
|
+
import { createModuleLoader } from './create-loader';
|
|
9
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
10
|
+
const DATABASE_SETTINGS_SQL = `
|
|
11
|
+
SELECT
|
|
12
|
+
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
|
|
13
|
+
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
|
|
14
|
+
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
|
|
15
|
+
COALESCE(aps.enable_direct_uploads, ds.enable_direct_uploads) AS resolved_enable_direct_uploads,
|
|
16
|
+
COALESCE(aps.enable_presigned_uploads, ds.enable_presigned_uploads) AS resolved_enable_presigned_uploads,
|
|
17
|
+
COALESCE(aps.enable_many_to_many, ds.enable_many_to_many) AS resolved_enable_many_to_many,
|
|
18
|
+
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
|
|
19
|
+
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
|
|
20
|
+
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
|
|
21
|
+
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
|
|
22
|
+
COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
|
|
23
|
+
FROM services_public.database_settings ds
|
|
24
|
+
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
|
|
25
|
+
WHERE ds.database_id = $1
|
|
26
|
+
LIMIT 1
|
|
27
|
+
`;
|
|
28
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
29
|
+
export const databaseSettingsLoader = createModuleLoader({
|
|
30
|
+
name: 'databaseSettings',
|
|
31
|
+
ttlMs: 5 * 60_000,
|
|
32
|
+
async resolve(ctx) {
|
|
33
|
+
const { servicesPool, databaseId, apiId } = ctx;
|
|
34
|
+
const result = await servicesPool.query(DATABASE_SETTINGS_SQL, [databaseId, apiId ?? null]);
|
|
35
|
+
const row = result.rows[0];
|
|
36
|
+
if (!row)
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
enableAggregates: row.resolved_enable_aggregates,
|
|
40
|
+
enablePostgis: row.resolved_enable_postgis,
|
|
41
|
+
enableSearch: row.resolved_enable_search,
|
|
42
|
+
enableDirectUploads: row.resolved_enable_direct_uploads,
|
|
43
|
+
enablePresignedUploads: row.resolved_enable_presigned_uploads,
|
|
44
|
+
enableManyToMany: row.resolved_enable_many_to_many,
|
|
45
|
+
enableConnectionFilter: row.resolved_enable_connection_filter,
|
|
46
|
+
enableLtree: row.resolved_enable_ltree,
|
|
47
|
+
enableLlm: row.resolved_enable_llm,
|
|
48
|
+
enableRealtime: row.resolved_enable_realtime,
|
|
49
|
+
enableBulk: row.resolved_enable_bulk,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Loaders — pluggable per-database cached lookups.
|
|
3
|
+
*
|
|
4
|
+
* Each loader encapsulates a SQL query + type transform + LRU cache
|
|
5
|
+
* for one piece of per-database configuration. Register loaders in
|
|
6
|
+
* a LoaderRegistry and pass it to createContextMiddleware().
|
|
7
|
+
*
|
|
8
|
+
* Built-in loaders cover the standard Constructive modules:
|
|
9
|
+
* - rlsModule (services_public.rls_settings)
|
|
10
|
+
* - corsOrigins (services_public.cors_settings)
|
|
11
|
+
* - databaseSettings(services_public.database_settings)
|
|
12
|
+
* - pubkeyChallengeSettings (services_public.pubkey_settings)
|
|
13
|
+
* - webauthnSettings(services_public.webauthn_settings)
|
|
14
|
+
* - authSettings (metaschema_modules_public.sessions_module → tenant DB)
|
|
15
|
+
*
|
|
16
|
+
* To add a new per-db lookup, implement a ModuleLoader and register it:
|
|
17
|
+
*
|
|
18
|
+
* const myLoader = createModuleLoader({
|
|
19
|
+
* name: 'myModule',
|
|
20
|
+
* ttlMs: 60_000,
|
|
21
|
+
* async resolve(ctx) {
|
|
22
|
+
* const { rows } = await ctx.tenantPool.query(MY_SQL, [ctx.databaseId]);
|
|
23
|
+
* return rows[0] ? transform(rows[0]) : undefined;
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
* registry.register(myLoader);
|
|
27
|
+
*/
|
|
28
|
+
export { createModuleLoader } from './create-loader';
|
|
29
|
+
export { createLoaderRegistry } from './registry';
|
|
30
|
+
// Built-in loaders
|
|
31
|
+
export { rlsLoader } from './rls';
|
|
32
|
+
export { corsLoader } from './cors';
|
|
33
|
+
export { databaseSettingsLoader } from './database-settings';
|
|
34
|
+
export { pubkeyLoader } from './pubkey';
|
|
35
|
+
export { webauthnLoader } from './webauthn';
|
|
36
|
+
export { authSettingsLoader } from './auth-settings';
|
|
37
|
+
/**
|
|
38
|
+
* Convenience: create a registry pre-loaded with all built-in loaders.
|
|
39
|
+
*/
|
|
40
|
+
import { createLoaderRegistry } from './registry';
|
|
41
|
+
import { rlsLoader } from './rls';
|
|
42
|
+
import { corsLoader } from './cors';
|
|
43
|
+
import { databaseSettingsLoader } from './database-settings';
|
|
44
|
+
import { pubkeyLoader } from './pubkey';
|
|
45
|
+
import { webauthnLoader } from './webauthn';
|
|
46
|
+
import { authSettingsLoader } from './auth-settings';
|
|
47
|
+
export function createDefaultRegistry() {
|
|
48
|
+
const registry = createLoaderRegistry();
|
|
49
|
+
registry.register(rlsLoader);
|
|
50
|
+
registry.register(corsLoader);
|
|
51
|
+
registry.register(databaseSettingsLoader);
|
|
52
|
+
registry.register(pubkeyLoader);
|
|
53
|
+
registry.register(webauthnLoader);
|
|
54
|
+
registry.register(authSettingsLoader);
|
|
55
|
+
return registry;
|
|
56
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pubkey Challenge Settings Loader
|
|
3
|
+
*
|
|
4
|
+
* Resolves public-key challenge auth config (crypto network, sign-in/sign-up
|
|
5
|
+
* function names). Tries the new pubkey_settings table first, falls back
|
|
6
|
+
* to the legacy api_modules approach.
|
|
7
|
+
*/
|
|
8
|
+
import { createModuleLoader } from './create-loader';
|
|
9
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
10
|
+
const PUBKEY_SETTINGS_SQL = `
|
|
11
|
+
SELECT
|
|
12
|
+
s.schema_name AS schema,
|
|
13
|
+
ps.crypto_network,
|
|
14
|
+
sign_up_fn.name AS sign_up_with_key,
|
|
15
|
+
sign_in_req_fn.name AS sign_in_request_challenge,
|
|
16
|
+
sign_in_fail_fn.name AS sign_in_record_failure,
|
|
17
|
+
sign_in_fn.name AS sign_in_with_challenge
|
|
18
|
+
FROM services_public.pubkey_settings ps
|
|
19
|
+
LEFT JOIN metaschema_public.schema s ON ps.schema_id = s.id
|
|
20
|
+
LEFT JOIN metaschema_public.function sign_up_fn ON ps.sign_up_with_key_function_id = sign_up_fn.id
|
|
21
|
+
LEFT JOIN metaschema_public.function sign_in_req_fn ON ps.sign_in_request_challenge_function_id = sign_in_req_fn.id
|
|
22
|
+
LEFT JOIN metaschema_public.function sign_in_fail_fn ON ps.sign_in_record_failure_function_id = sign_in_fail_fn.id
|
|
23
|
+
LEFT JOIN metaschema_public.function sign_in_fn ON ps.sign_in_with_challenge_function_id = sign_in_fn.id
|
|
24
|
+
WHERE ps.database_id = $1
|
|
25
|
+
LIMIT 1
|
|
26
|
+
`;
|
|
27
|
+
const PUBKEY_MODULE_SQL = `
|
|
28
|
+
SELECT data
|
|
29
|
+
FROM services_public.api_modules
|
|
30
|
+
WHERE api_id = $1 AND name = 'pubkey_challenge'
|
|
31
|
+
LIMIT 1
|
|
32
|
+
`;
|
|
33
|
+
// ─── Transforms ─────────────────────────────────────────────────────────────
|
|
34
|
+
function fromRow(row) {
|
|
35
|
+
if (!row?.schema || !row?.sign_up_with_key)
|
|
36
|
+
return undefined;
|
|
37
|
+
return {
|
|
38
|
+
schema: row.schema,
|
|
39
|
+
cryptoNetwork: row.crypto_network,
|
|
40
|
+
signUpWithKey: row.sign_up_with_key,
|
|
41
|
+
signInRequestChallenge: row.sign_in_request_challenge,
|
|
42
|
+
signInRecordFailure: row.sign_in_record_failure,
|
|
43
|
+
signInWithChallenge: row.sign_in_with_challenge,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
47
|
+
export const pubkeyLoader = createModuleLoader({
|
|
48
|
+
name: 'pubkeyChallengeSettings',
|
|
49
|
+
ttlMs: 5 * 60_000,
|
|
50
|
+
async resolve(ctx) {
|
|
51
|
+
const { servicesPool, databaseId, apiId } = ctx;
|
|
52
|
+
// Try new pubkey_settings table first
|
|
53
|
+
try {
|
|
54
|
+
const result = await servicesPool.query(PUBKEY_SETTINGS_SQL, [databaseId]);
|
|
55
|
+
const resolved = fromRow(result.rows[0] ?? null);
|
|
56
|
+
if (resolved)
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Table may not exist yet
|
|
61
|
+
}
|
|
62
|
+
// Fall back to legacy api_modules
|
|
63
|
+
if (apiId) {
|
|
64
|
+
const result = await servicesPool.query(PUBKEY_MODULE_SQL, [apiId]);
|
|
65
|
+
return fromRow(result.rows[0]?.data ?? null);
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loader Registry — manages a set of ModuleLoader instances.
|
|
3
|
+
*
|
|
4
|
+
* Supports two resolution modes:
|
|
5
|
+
* - Lazy (preferred): `resolve(name, ctx)` resolves a single loader
|
|
6
|
+
* on-demand. Only fires the SQL query if the cache misses. Middleware
|
|
7
|
+
* requests only the loaders it actually needs.
|
|
8
|
+
* - Eager: `resolveAll(ctx)` resolves every registered loader in
|
|
9
|
+
* parallel. Useful for pre-warming or migration from the monolithic
|
|
10
|
+
* svcCache pattern.
|
|
11
|
+
*
|
|
12
|
+
* Each loader's result is independently cached per databaseId — resolving
|
|
13
|
+
* one module never invalidates another.
|
|
14
|
+
*/
|
|
15
|
+
import { Logger } from '@pgpmjs/logger';
|
|
16
|
+
const log = new Logger('loader-registry');
|
|
17
|
+
export function createLoaderRegistry() {
|
|
18
|
+
const loaders = new Map();
|
|
19
|
+
return {
|
|
20
|
+
register(loader) {
|
|
21
|
+
if (loaders.has(loader.name)) {
|
|
22
|
+
throw new Error(`Loader "${loader.name}" is already registered`);
|
|
23
|
+
}
|
|
24
|
+
loaders.set(loader.name, loader);
|
|
25
|
+
log.debug(`Registered loader: ${loader.name}`);
|
|
26
|
+
},
|
|
27
|
+
async resolve(name, ctx) {
|
|
28
|
+
const loader = loaders.get(name);
|
|
29
|
+
if (!loader) {
|
|
30
|
+
log.debug(`Loader "${name}" not registered, returning undefined`);
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return loader.resolve(ctx);
|
|
34
|
+
},
|
|
35
|
+
async resolveAll(ctx) {
|
|
36
|
+
if (loaders.size === 0)
|
|
37
|
+
return {};
|
|
38
|
+
const entries = Array.from(loaders.entries());
|
|
39
|
+
const results = await Promise.all(entries.map(async ([name, loader]) => {
|
|
40
|
+
const value = await loader.resolve(ctx);
|
|
41
|
+
return [name, value];
|
|
42
|
+
}));
|
|
43
|
+
const modules = {};
|
|
44
|
+
for (const [name, value] of results) {
|
|
45
|
+
if (value !== undefined) {
|
|
46
|
+
modules[name] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return modules;
|
|
50
|
+
},
|
|
51
|
+
get(name) {
|
|
52
|
+
return loaders.get(name);
|
|
53
|
+
},
|
|
54
|
+
has(name) {
|
|
55
|
+
return loaders.has(name);
|
|
56
|
+
},
|
|
57
|
+
invalidate(databaseId) {
|
|
58
|
+
for (const loader of loaders.values()) {
|
|
59
|
+
loader.invalidate(databaseId);
|
|
60
|
+
}
|
|
61
|
+
log.debug(databaseId
|
|
62
|
+
? `Invalidated all loaders for databaseId=${databaseId}`
|
|
63
|
+
: 'Invalidated all loaders');
|
|
64
|
+
},
|
|
65
|
+
get names() {
|
|
66
|
+
return Array.from(loaders.keys());
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLS Module Loader
|
|
3
|
+
*
|
|
4
|
+
* Resolves RLS authentication function names and schema references for
|
|
5
|
+
* a given database. Tries the new rls_settings table first, falls back
|
|
6
|
+
* to the legacy api_modules approach.
|
|
7
|
+
*/
|
|
8
|
+
import { createModuleLoader } from './create-loader';
|
|
9
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
10
|
+
const RLS_SETTINGS_SQL = `
|
|
11
|
+
SELECT
|
|
12
|
+
auth_schema.schema_name AS authenticate_schema,
|
|
13
|
+
role_schema.schema_name AS role_schema,
|
|
14
|
+
auth_fn.name AS authenticate,
|
|
15
|
+
auth_strict_fn.name AS authenticate_strict,
|
|
16
|
+
role_fn.name AS current_role,
|
|
17
|
+
role_id_fn.name AS current_role_id,
|
|
18
|
+
ua_fn.name AS current_user_agent,
|
|
19
|
+
ip_fn.name AS current_ip_address
|
|
20
|
+
FROM services_public.rls_settings rs
|
|
21
|
+
LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id
|
|
22
|
+
LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id
|
|
23
|
+
LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id
|
|
24
|
+
LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id
|
|
25
|
+
LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id
|
|
26
|
+
LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id
|
|
27
|
+
LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id
|
|
28
|
+
LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id
|
|
29
|
+
WHERE rs.database_id = $1
|
|
30
|
+
LIMIT 1
|
|
31
|
+
`;
|
|
32
|
+
const RLS_MODULE_SQL = `
|
|
33
|
+
SELECT data
|
|
34
|
+
FROM services_public.api_modules
|
|
35
|
+
WHERE api_id = $1 AND name = 'rls_module'
|
|
36
|
+
LIMIT 1
|
|
37
|
+
`;
|
|
38
|
+
// ─── Transforms ─────────────────────────────────────────────────────────────
|
|
39
|
+
function fromSettings(row) {
|
|
40
|
+
if (!row)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (!row.authenticate || !row.authenticate_schema)
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
authenticate: row.authenticate,
|
|
46
|
+
authenticateStrict: row.authenticate_strict,
|
|
47
|
+
privateSchema: { schemaName: row.authenticate_schema },
|
|
48
|
+
publicSchema: { schemaName: row.role_schema },
|
|
49
|
+
currentRole: row.current_role,
|
|
50
|
+
currentRoleId: row.current_role_id,
|
|
51
|
+
currentIpAddress: row.current_ip_address,
|
|
52
|
+
currentUserAgent: row.current_user_agent,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function fromModule(row) {
|
|
56
|
+
if (!row?.data)
|
|
57
|
+
return undefined;
|
|
58
|
+
const d = row.data;
|
|
59
|
+
return {
|
|
60
|
+
authenticate: d.authenticate,
|
|
61
|
+
authenticateStrict: d.authenticate_strict,
|
|
62
|
+
privateSchema: { schemaName: d.authenticate_schema },
|
|
63
|
+
publicSchema: { schemaName: d.role_schema },
|
|
64
|
+
currentRole: d.current_role,
|
|
65
|
+
currentRoleId: d.current_role_id,
|
|
66
|
+
currentIpAddress: d.current_ip_address,
|
|
67
|
+
currentUserAgent: d.current_user_agent,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
71
|
+
export const rlsLoader = createModuleLoader({
|
|
72
|
+
name: 'rlsModule',
|
|
73
|
+
ttlMs: 5 * 60_000,
|
|
74
|
+
async resolve(ctx) {
|
|
75
|
+
const { servicesPool, databaseId, apiId } = ctx;
|
|
76
|
+
// Try new rls_settings table first
|
|
77
|
+
try {
|
|
78
|
+
const result = await servicesPool.query(RLS_SETTINGS_SQL, [databaseId]);
|
|
79
|
+
const resolved = fromSettings(result.rows[0] ?? null);
|
|
80
|
+
if (resolved)
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Table may not exist yet
|
|
85
|
+
}
|
|
86
|
+
// Fall back to legacy api_modules
|
|
87
|
+
if (apiId) {
|
|
88
|
+
const result = await servicesPool.query(RLS_MODULE_SQL, [apiId]);
|
|
89
|
+
return fromModule(result.rows[0] ?? null);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Loader Types
|
|
3
|
+
*
|
|
4
|
+
* A ModuleLoader is a per-database cached lookup that resolves config
|
|
5
|
+
* from the services DB or tenant DB. Each loader owns its own LRU cache
|
|
6
|
+
* keyed by databaseId, with independent TTL and eviction.
|
|
7
|
+
*
|
|
8
|
+
* Loaders are registered in a LoaderRegistry and resolved in parallel
|
|
9
|
+
* during context building. The result is a typed modules map on
|
|
10
|
+
* `req.constructive.modules`.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn Settings Loader
|
|
3
|
+
*
|
|
4
|
+
* Resolves WebAuthn/passkey configuration for a database — RP identity,
|
|
5
|
+
* schema references, attestation policy, and challenge expiry.
|
|
6
|
+
*/
|
|
7
|
+
import { createModuleLoader } from './create-loader';
|
|
8
|
+
// ─── SQL ────────────────────────────────────────────────────────────────────
|
|
9
|
+
const WEBAUTHN_SETTINGS_SQL = `
|
|
10
|
+
SELECT
|
|
11
|
+
s.schema_name AS schema,
|
|
12
|
+
cred_s.schema_name AS credentials_schema,
|
|
13
|
+
sess_s.schema_name AS sessions_schema,
|
|
14
|
+
sec_s.schema_name AS session_secrets_schema,
|
|
15
|
+
ws.rp_id,
|
|
16
|
+
ws.rp_name,
|
|
17
|
+
ws.origin_allowlist,
|
|
18
|
+
ws.attestation_type,
|
|
19
|
+
ws.require_user_verification,
|
|
20
|
+
ws.resident_key,
|
|
21
|
+
ws.challenge_expiry_seconds
|
|
22
|
+
FROM services_public.webauthn_settings ws
|
|
23
|
+
LEFT JOIN metaschema_public.schema s ON ws.schema_id = s.id
|
|
24
|
+
LEFT JOIN metaschema_public.schema cred_s ON ws.credentials_schema_id = cred_s.id
|
|
25
|
+
LEFT JOIN metaschema_public.schema sess_s ON ws.sessions_schema_id = sess_s.id
|
|
26
|
+
LEFT JOIN metaschema_public.schema sec_s ON ws.session_secrets_schema_id = sec_s.id
|
|
27
|
+
WHERE ws.database_id = $1
|
|
28
|
+
LIMIT 1
|
|
29
|
+
`;
|
|
30
|
+
// ─── Loader ─────────────────────────────────────────────────────────────────
|
|
31
|
+
export const webauthnLoader = createModuleLoader({
|
|
32
|
+
name: 'webauthnSettings',
|
|
33
|
+
ttlMs: 5 * 60_000,
|
|
34
|
+
async resolve(ctx) {
|
|
35
|
+
const { servicesPool, databaseId } = ctx;
|
|
36
|
+
const result = await servicesPool.query(WEBAUTHN_SETTINGS_SQL, [databaseId]);
|
|
37
|
+
const row = result.rows[0];
|
|
38
|
+
if (!row?.schema)
|
|
39
|
+
return undefined;
|
|
40
|
+
return {
|
|
41
|
+
schema: row.schema,
|
|
42
|
+
credentialsSchema: row.credentials_schema,
|
|
43
|
+
sessionsSchema: row.sessions_schema,
|
|
44
|
+
sessionSecretsSchema: row.session_secrets_schema,
|
|
45
|
+
rpId: row.rp_id,
|
|
46
|
+
rpName: row.rp_name,
|
|
47
|
+
originAllowlist: row.origin_allowlist,
|
|
48
|
+
attestationType: row.attestation_type,
|
|
49
|
+
requireUserVerification: row.require_user_verification,
|
|
50
|
+
residentKey: row.resident_key,
|
|
51
|
+
challengeExpirySeconds: row.challenge_expiry_seconds,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
package/esm/pg-client.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg-client — Re-exports withPgClient from pg-query-context
|
|
3
|
+
*
|
|
4
|
+
* This module exists for backwards compatibility. The implementation
|
|
5
|
+
* lives in pg-query-context which provides both the single-query API
|
|
6
|
+
* and the callback-based withPgClient API.
|
|
7
|
+
*/
|
|
8
|
+
export { withPgClient } from 'pg-query-context';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg-settings — Build pgSettings from resolved API + auth token
|
|
3
|
+
*
|
|
4
|
+
* pgSettings are key-value pairs passed to PostgreSQL via SET LOCAL
|
|
5
|
+
* within each transaction. They carry the JWT claims, role, database_id,
|
|
6
|
+
* and request_id so that RLS policies and SQL functions can reference
|
|
7
|
+
* the current user context via `current_setting('jwt.claims.user_id')`.
|
|
8
|
+
*
|
|
9
|
+
* This module extracts the pgSettings construction so it's reusable
|
|
10
|
+
* across the PostGraphile server, LLM sidecar, or any Express service.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Build pgSettings from the resolved API + auth token.
|
|
14
|
+
*
|
|
15
|
+
* These settings are applied via SET LOCAL in each transaction,
|
|
16
|
+
* making them available to RLS policies and SQL functions.
|
|
17
|
+
*/
|
|
18
|
+
export function buildPgSettings(input) {
|
|
19
|
+
const { api, token, requestId, clientIp } = input;
|
|
20
|
+
const settings = {};
|
|
21
|
+
// Role: from token (authenticated) or api (anonymous fallback)
|
|
22
|
+
if (token?.user_id) {
|
|
23
|
+
settings['role'] = api.roleName || 'authenticated';
|
|
24
|
+
settings['jwt.claims.user_id'] = token.user_id;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
settings['role'] = api.anonRole || 'anonymous';
|
|
28
|
+
}
|
|
29
|
+
// Session claims
|
|
30
|
+
if (token?.session_id) {
|
|
31
|
+
settings['jwt.claims.session_id'] = token.session_id;
|
|
32
|
+
}
|
|
33
|
+
// Database context
|
|
34
|
+
if (api.databaseId) {
|
|
35
|
+
settings['jwt.claims.database_id'] = api.databaseId;
|
|
36
|
+
}
|
|
37
|
+
// Distributed tracing
|
|
38
|
+
settings['request.id'] = requestId;
|
|
39
|
+
// Client metadata (for audit functions)
|
|
40
|
+
if (clientIp) {
|
|
41
|
+
settings['jwt.claims.ip_address'] = clientIp;
|
|
42
|
+
}
|
|
43
|
+
return settings;
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-id — Per-request correlation ID middleware
|
|
3
|
+
*
|
|
4
|
+
* Generates a UUID v4 request ID (or uses an incoming X-Request-Id header)
|
|
5
|
+
* and attaches it to `req.requestId`. This ID propagates through pgSettings
|
|
6
|
+
* into billing ledger entries and inference logs for distributed tracing.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that ensures every request has a correlation ID.
|
|
11
|
+
*
|
|
12
|
+
* Priority: `X-Request-Id` header → new UUID v4
|
|
13
|
+
*/
|
|
14
|
+
export function requestIdMiddleware() {
|
|
15
|
+
return (req, _res, next) => {
|
|
16
|
+
req.requestId = (req.get('X-Request-Id') || randomUUID());
|
|
17
|
+
next();
|
|
18
|
+
};
|
|
19
|
+
}
|
package/esm/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Constructive Express middleware.
|
|
3
|
+
*
|
|
4
|
+
* These types describe the resolved API structure, RLS module, auth settings,
|
|
5
|
+
* database feature flags, and other config that middleware attaches to the
|
|
6
|
+
* Express request. Extracted from graphql/server so any Express-based service
|
|
7
|
+
* (PostGraphile, LLM sidecar, etc.) can share the same context.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @constructive-io/express-context
|
|
3
|
+
*
|
|
4
|
+
* Extractable Express middleware for Constructive tenant context.
|
|
5
|
+
*
|
|
6
|
+
* Provides:
|
|
7
|
+
* - Shared types (ApiStructure, RlsModule, AuthSettings, etc.)
|
|
8
|
+
* - pgSettings builder (role, JWT claims, request_id, database_id)
|
|
9
|
+
* - withPgClient (tenant-scoped RLS transaction helper)
|
|
10
|
+
* - requestId middleware (UUID correlation ID)
|
|
11
|
+
* - Context middleware (composes all of the above into req.constructive)
|
|
12
|
+
* - Module loaders (pluggable per-database cached lookups)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import {
|
|
17
|
+
* createContextMiddleware,
|
|
18
|
+
* requestIdMiddleware,
|
|
19
|
+
* createDefaultRegistry,
|
|
20
|
+
* } from '@constructive-io/express-context';
|
|
21
|
+
*
|
|
22
|
+
* const loaders = createDefaultRegistry();
|
|
23
|
+
*
|
|
24
|
+
* app.use(requestIdMiddleware());
|
|
25
|
+
* app.use(apiMiddleware); // sets req.api (your domain resolver)
|
|
26
|
+
* app.use(authMiddleware); // sets req.token (your JWT verifier)
|
|
27
|
+
* app.use(createContextMiddleware({ loaders })); // builds req.constructive
|
|
28
|
+
*
|
|
29
|
+
* app.post('/v1/chat', async (req, res) => {
|
|
30
|
+
* const ctx = req.constructive;
|
|
31
|
+
* const rls = await ctx.useModule('rlsModule'); // only fires if not cached
|
|
32
|
+
* const auth = await ctx.useModule('authSettings'); // only fires if not cached
|
|
33
|
+
* // webauthnSettings loader never fires if nobody asks for it
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export type { ApiConfigResult, ApiError, ApiModule, ApiStructure, AuthSettings, ConstructiveAPIToken, ConstructiveContext, CorsModuleData, DatabaseSettings, GenericModuleData, PublicKeyChallengeData, PubkeyChallengeSettings, BuiltinModuleMap, RlsModule, WebauthnSettings, WithPgClient, } from './types';
|
|
38
|
+
export type { PgSettingsInput } from './pg-settings';
|
|
39
|
+
export { buildPgSettings } from './pg-settings';
|
|
40
|
+
export { withPgClient } from './pg-client';
|
|
41
|
+
export { requestIdMiddleware } from './request-id';
|
|
42
|
+
export type { ContextMiddlewareOptions } from './context';
|
|
43
|
+
export { buildContext, createContextMiddleware } from './context';
|
|
44
|
+
export type { CreateLoaderOptions, LoaderContext, LoaderRegistry, ModuleLoader, } from './loaders';
|
|
45
|
+
export { authSettingsLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, pubkeyLoader, rlsLoader, webauthnLoader, } from './loaders';
|
|
46
|
+
import './types';
|
package/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @constructive-io/express-context
|
|
4
|
+
*
|
|
5
|
+
* Extractable Express middleware for Constructive tenant context.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Shared types (ApiStructure, RlsModule, AuthSettings, etc.)
|
|
9
|
+
* - pgSettings builder (role, JWT claims, request_id, database_id)
|
|
10
|
+
* - withPgClient (tenant-scoped RLS transaction helper)
|
|
11
|
+
* - requestId middleware (UUID correlation ID)
|
|
12
|
+
* - Context middleware (composes all of the above into req.constructive)
|
|
13
|
+
* - Module loaders (pluggable per-database cached lookups)
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import {
|
|
18
|
+
* createContextMiddleware,
|
|
19
|
+
* requestIdMiddleware,
|
|
20
|
+
* createDefaultRegistry,
|
|
21
|
+
* } from '@constructive-io/express-context';
|
|
22
|
+
*
|
|
23
|
+
* const loaders = createDefaultRegistry();
|
|
24
|
+
*
|
|
25
|
+
* app.use(requestIdMiddleware());
|
|
26
|
+
* app.use(apiMiddleware); // sets req.api (your domain resolver)
|
|
27
|
+
* app.use(authMiddleware); // sets req.token (your JWT verifier)
|
|
28
|
+
* app.use(createContextMiddleware({ loaders })); // builds req.constructive
|
|
29
|
+
*
|
|
30
|
+
* app.post('/v1/chat', async (req, res) => {
|
|
31
|
+
* const ctx = req.constructive;
|
|
32
|
+
* const rls = await ctx.useModule('rlsModule'); // only fires if not cached
|
|
33
|
+
* const auth = await ctx.useModule('authSettings'); // only fires if not cached
|
|
34
|
+
* // webauthnSettings loader never fires if nobody asks for it
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.webauthnLoader = exports.rlsLoader = exports.pubkeyLoader = exports.databaseSettingsLoader = exports.createModuleLoader = exports.createLoaderRegistry = exports.createDefaultRegistry = exports.corsLoader = exports.authSettingsLoader = exports.createContextMiddleware = exports.buildContext = exports.requestIdMiddleware = exports.withPgClient = exports.buildPgSettings = void 0;
|
|
40
|
+
var pg_settings_1 = require("./pg-settings");
|
|
41
|
+
Object.defineProperty(exports, "buildPgSettings", { enumerable: true, get: function () { return pg_settings_1.buildPgSettings; } });
|
|
42
|
+
// withPgClient helper
|
|
43
|
+
var pg_client_1 = require("./pg-client");
|
|
44
|
+
Object.defineProperty(exports, "withPgClient", { enumerable: true, get: function () { return pg_client_1.withPgClient; } });
|
|
45
|
+
// Request ID middleware
|
|
46
|
+
var request_id_1 = require("./request-id");
|
|
47
|
+
Object.defineProperty(exports, "requestIdMiddleware", { enumerable: true, get: function () { return request_id_1.requestIdMiddleware; } });
|
|
48
|
+
var context_1 = require("./context");
|
|
49
|
+
Object.defineProperty(exports, "buildContext", { enumerable: true, get: function () { return context_1.buildContext; } });
|
|
50
|
+
Object.defineProperty(exports, "createContextMiddleware", { enumerable: true, get: function () { return context_1.createContextMiddleware; } });
|
|
51
|
+
var loaders_1 = require("./loaders");
|
|
52
|
+
Object.defineProperty(exports, "authSettingsLoader", { enumerable: true, get: function () { return loaders_1.authSettingsLoader; } });
|
|
53
|
+
Object.defineProperty(exports, "corsLoader", { enumerable: true, get: function () { return loaders_1.corsLoader; } });
|
|
54
|
+
Object.defineProperty(exports, "createDefaultRegistry", { enumerable: true, get: function () { return loaders_1.createDefaultRegistry; } });
|
|
55
|
+
Object.defineProperty(exports, "createLoaderRegistry", { enumerable: true, get: function () { return loaders_1.createLoaderRegistry; } });
|
|
56
|
+
Object.defineProperty(exports, "createModuleLoader", { enumerable: true, get: function () { return loaders_1.createModuleLoader; } });
|
|
57
|
+
Object.defineProperty(exports, "databaseSettingsLoader", { enumerable: true, get: function () { return loaders_1.databaseSettingsLoader; } });
|
|
58
|
+
Object.defineProperty(exports, "pubkeyLoader", { enumerable: true, get: function () { return loaders_1.pubkeyLoader; } });
|
|
59
|
+
Object.defineProperty(exports, "rlsLoader", { enumerable: true, get: function () { return loaders_1.rlsLoader; } });
|
|
60
|
+
Object.defineProperty(exports, "webauthnLoader", { enumerable: true, get: function () { return loaders_1.webauthnLoader; } });
|
|
61
|
+
// Side-effect: Express type augmentation
|
|
62
|
+
require("./types");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Settings Loader (Tier 2 — tenant DB)
|
|
3
|
+
*
|
|
4
|
+
* Two-step discovery:
|
|
5
|
+
* 1. Query metaschema_modules_public.sessions_module to find the
|
|
6
|
+
* schema name and table name for auth settings
|
|
7
|
+
* 2. Query the discovered <schema>.<table> in the tenant DB
|
|
8
|
+
*
|
|
9
|
+
* This is the pattern for any module whose config lives in the tenant
|
|
10
|
+
* database rather than the services database.
|
|
11
|
+
*/
|
|
12
|
+
import type { AuthSettings } from '../types';
|
|
13
|
+
import type { ModuleLoader } from './types';
|
|
14
|
+
export declare const authSettingsLoader: ModuleLoader<AuthSettings>;
|