From 0aa60ee7ccf2066a419301e1ec553119cde61bfb Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 12 Jul 2026 13:55:35 +0000 Subject: [PATCH] feat: inject jwt.claims.api_id provenance claim and add REST /fn routes - Set jwt.claims.api_id transaction-locally via pgSettings for every request on an API surface, derived from the server-side hostname -> services_public.domains -> api_id resolution (never client input) - Add compute module loader (function/invocation module metadata) - Add POST /fn/:alias and GET /fn/invocations/:id REST routes resolving function_api_bindings by (api_id, alias); 404 on missing binding, rest-disabled config, or disallowed method; RLS enforced end to end - ajv/JSON-Schema payload validation intentionally deferred (TODO hook) Refs constructive-io/constructive-planning#1149 --- __fixtures__/seed/compute/setup.sql | 101 ++++++++++ __fixtures__/seed/compute/test-data.sql | 71 +++++++ .../__tests__/fn-routes.integration.test.ts | 147 ++++++++++++++ graphql/server/src/middleware/fn.ts | 189 ++++++++++++++++++ graphql/server/src/middleware/graphile.ts | 6 + graphql/server/src/server.ts | 8 +- .../__tests__/pg-settings.test.ts | 52 +++++ packages/express-context/src/index.ts | 2 + .../express-context/src/loaders/compute.ts | 65 ++++++ packages/express-context/src/loaders/index.ts | 3 + packages/express-context/src/pg-settings.ts | 7 + packages/express-context/src/types.ts | 9 + 12 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 __fixtures__/seed/compute/setup.sql create mode 100644 __fixtures__/seed/compute/test-data.sql create mode 100644 graphql/server-test/__tests__/fn-routes.integration.test.ts create mode 100644 graphql/server/src/middleware/fn.ts create mode 100644 packages/express-context/__tests__/pg-settings.test.ts create mode 100644 packages/express-context/src/loaders/compute.ts diff --git a/__fixtures__/seed/compute/setup.sql b/__fixtures__/seed/compute/setup.sql new file mode 100644 index 000000000..e831aea88 --- /dev/null +++ b/__fixtures__/seed/compute/setup.sql @@ -0,0 +1,101 @@ +-- Shared fixture: compute (functions) module DDL +-- +-- Creates the minimum tables needed to emulate the compute module in a +-- production Constructive database: the metaschema module registration +-- tables (function_module, function_invocation_module) plus the generated +-- compute tables (function_definitions, function_api_bindings, +-- function_invocations). +-- +-- The function_invocations INSERT policy mirrors the api arm of the +-- production policy: the row is only insertable when the transaction-local +-- `jwt.claims.api_id` claim matches a binding for the invoked function. +-- +-- Depends on: services/setup.sql + +-- ===================================================== +-- METASCHEMA MODULE REGISTRATION TABLES +-- ===================================================== + +CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_module ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + definitions_table_name text NOT NULL DEFAULT 'function_definitions', + scope text NOT NULL DEFAULT 'app' +); + +CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_invocation_module ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + schema_id uuid NOT NULL, + invocations_table_name text NOT NULL DEFAULT 'function_invocations', + scope text NOT NULL DEFAULT 'app' +); + +GRANT SELECT ON metaschema_modules_public.function_module TO administrator, authenticated, anonymous; +GRANT SELECT ON metaschema_modules_public.function_invocation_module TO administrator, authenticated, anonymous; + +-- ===================================================== +-- COMPUTE SCHEMA + GENERATED TABLES +-- ===================================================== + +CREATE SCHEMA IF NOT EXISTS compute_public; +GRANT USAGE ON SCHEMA compute_public TO administrator, authenticated, anonymous; + +CREATE TABLE IF NOT EXISTS compute_public.function_definitions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + name text NOT NULL, + task_identifier text NOT NULL +); + +CREATE TABLE IF NOT EXISTS compute_public.function_api_bindings ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + function_definition_id uuid NOT NULL REFERENCES compute_public.function_definitions (id) ON DELETE CASCADE, + api_id uuid NOT NULL, + alias text NOT NULL, + config jsonb NOT NULL DEFAULT '{}', + UNIQUE (api_id, alias) +); + +CREATE TABLE IF NOT EXISTS compute_public.function_invocations ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + database_id uuid NOT NULL, + task_identifier text NOT NULL, + payload jsonb, + status text NOT NULL DEFAULT 'pending', + result jsonb, + error jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz, + duration_ms int +); + +GRANT SELECT ON compute_public.function_definitions TO administrator, authenticated, anonymous; +GRANT SELECT ON compute_public.function_api_bindings TO administrator, authenticated, anonymous; +GRANT SELECT, INSERT ON compute_public.function_invocations TO administrator, authenticated, anonymous; + +-- ===================================================== +-- RLS — api-arm provenance policy +-- ===================================================== + +ALTER TABLE compute_public.function_invocations ENABLE ROW LEVEL SECURITY; + +-- INSERT is only allowed when the server-injected jwt.claims.api_id claim +-- matches a binding for the invoked function (api provenance arm). +CREATE POLICY function_invocations_api_insert ON compute_public.function_invocations + FOR INSERT TO administrator, authenticated, anonymous + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM compute_public.function_api_bindings b + JOIN compute_public.function_definitions d ON d.id = b.function_definition_id + WHERE d.task_identifier = function_invocations.task_identifier + AND b.api_id = nullif(current_setting('jwt.claims.api_id', true), '')::uuid + ) + ); + +CREATE POLICY function_invocations_select ON compute_public.function_invocations + FOR SELECT TO administrator, authenticated, anonymous + USING (true); diff --git a/__fixtures__/seed/compute/test-data.sql b/__fixtures__/seed/compute/test-data.sql new file mode 100644 index 000000000..f390dc3f5 --- /dev/null +++ b/__fixtures__/seed/compute/test-data.sql @@ -0,0 +1,71 @@ +-- Shared fixture: compute (functions) module test data +-- +-- Registers the compute_public schema in the metaschema, provisions the +-- function/invocation modules, and seeds one function definition with two +-- API bindings on the "app" API (6c9997a4-591b-4cb3-9313-4ef45d6f134e): +-- - alias "resize" → rest enabled ({"rest": {"path": "/resize", "methods": ["POST"]}}) +-- - alias "graphql-only" → rest disabled (absent "rest" key) +-- +-- Depends on: services/setup.sql, services/test-data.sql, compute/setup.sql + +SET session_replication_role TO replica; + +-- Register compute_public in the metaschema +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public) +VALUES ( + '9dba0001-0000-4000-8000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'compute_public', + 'compute_public', + NULL, + true +) ON CONFLICT (id) DO NOTHING; + +-- Module registrations (what the compute loader resolves) +INSERT INTO metaschema_modules_public.function_module (id, database_id, schema_id, definitions_table_name, scope) +VALUES ( + '9dba0002-0000-4000-8000-000000000002', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '9dba0001-0000-4000-8000-000000000001', + 'function_definitions', + 'app' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_modules_public.function_invocation_module (id, database_id, schema_id, invocations_table_name, scope) +VALUES ( + '9dba0003-0000-4000-8000-000000000003', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '9dba0001-0000-4000-8000-000000000001', + 'function_invocations', + 'app' +) ON CONFLICT (id) DO NOTHING; + +-- Function definition +INSERT INTO compute_public.function_definitions (id, database_id, name, task_identifier) +VALUES ( + '9dba0004-0000-4000-8000-000000000004', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'resize-image', + 'app/resize_image' +) ON CONFLICT (id) DO NOTHING; + +-- Bindings on the "app" API +INSERT INTO compute_public.function_api_bindings (id, function_definition_id, api_id, alias, config) +VALUES + ( + '9dba0005-0000-4000-8000-000000000005', + '9dba0004-0000-4000-8000-000000000004', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'resize', + '{"graphql": true, "rest": {"path": "/resize", "methods": ["POST"]}}' + ), + ( + '9dba0006-0000-4000-8000-000000000006', + '9dba0004-0000-4000-8000-000000000004', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'graphql-only', + '{"graphql": true}' + ) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/__tests__/fn-routes.integration.test.ts b/graphql/server-test/__tests__/fn-routes.integration.test.ts new file mode 100644 index 000000000..4e8d76ac5 --- /dev/null +++ b/graphql/server-test/__tests__/fn-routes.integration.test.ts @@ -0,0 +1,147 @@ +/** + * REST Function Route Integration Tests + * + * Verifies the /fn REST routes and the server-injected API provenance + * claim (`jwt.claims.api_id`): + * + * POST /fn/:alias → 202 { invocationId } for a rest-enabled binding + * GET /fn/invocations/:id → invocation status/result (RLS-guarded read) + * + * The seeded function_invocations INSERT policy only passes when + * `jwt.claims.api_id` (set transaction-locally by the server from the + * hostname → services_public.domains → api_id resolution) matches the + * binding's api_id — so the happy path also proves the claim is injected. + * + * Run tests: + * pnpm test -- --testPathPattern=fn-routes.integration + */ + +import path from 'path'; +import { getConnections, seed } from '../src'; +import type supertest from 'supertest'; + +jest.setTimeout(30000); + +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); +const schemas = ['simple-pets-public', 'simple-pets-pets-public']; +const metaSchemas = [ + 'services_public', + 'metaschema_public', + 'metaschema_modules_public', +]; + +const seedFiles = [ + shared('services', 'setup.sql'), + shared('compute', 'setup.sql'), + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('services', 'test-data.sql'), + shared('compute', 'test-data.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), +]; + +const API_HOST = 'app.test.constructive.io'; + +let request: supertest.Agent; +let teardown: () => Promise; + +beforeAll(async () => { + ({ request, teardown } = await getConnections( + { + schemas, + authRole: 'anonymous', + server: { + api: { + enableServicesApi: true, + isPublic: true, + metaSchemas, + }, + }, + }, + [seed.sqlfile(seedFiles)] + )); +}); + +afterAll(async () => { + await teardown(); +}); + +describe('POST /fn/:alias', () => { + it('returns 202 with an invocationId for a rest-enabled binding', async () => { + const res = await request + .post('/fn/resize') + .set('Host', API_HOST) + .send({ width: 100, height: 100 }); + + expect(res.status).toBe(202); + expect(res.body.invocationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('the created invocation passed the jwt.claims.api_id RLS policy', async () => { + // The INSERT policy on function_invocations requires + // jwt.claims.api_id to match the binding's api_id, so a successful + // insert proves the server injected the claim transaction-locally. + const create = await request + .post('/fn/resize') + .set('Host', API_HOST) + .send({ check: 'claim' }); + + expect(create.status).toBe(202); + + const read = await request + .get(`/fn/invocations/${create.body.invocationId}`) + .set('Host', API_HOST); + + expect(read.status).toBe(200); + expect(read.body.id).toBe(create.body.invocationId); + expect(read.body.status).toBe('pending'); + }); + + it('returns 404 for a missing binding', async () => { + const res = await request + .post('/fn/nonexistent') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 404 for a rest-disabled binding (absent rest config)', async () => { + const res = await request + .post('/fn/graphql-only') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); + + it('returns 404 when the HTTP method is not allowed by config.rest', async () => { + const res = await request + .put('/fn/resize') + .set('Host', API_HOST) + .send({}); + + expect(res.status).toBe(404); + }); +}); + +describe('GET /fn/invocations/:id', () => { + it('returns 404 for an unknown invocation id', async () => { + const res = await request + .get('/fn/invocations/00000000-0000-4000-8000-000000000000') + .set('Host', API_HOST); + + expect(res.status).toBe(404); + }); + + it('returns 404 for a non-uuid id', async () => { + const res = await request + .get('/fn/invocations/not-a-uuid') + .set('Host', API_HOST); + + expect(res.status).toBe(404); + }); +}); diff --git a/graphql/server/src/middleware/fn.ts b/graphql/server/src/middleware/fn.ts new file mode 100644 index 000000000..deca6b026 --- /dev/null +++ b/graphql/server/src/middleware/fn.ts @@ -0,0 +1,189 @@ +/** + * fn — REST function invocation routes + * + * POST /fn/:alias → invoke a function bound to this API (202 { invocationId }) + * GET /fn/invocations/:id → read invocation status/result + * + * Routing is per-API: bindings are looked up in the function_api_bindings + * table by (api_id, alias), where api_id comes from the server-side domain + * resolution (req.constructive.api.apiId) — never from client input. + * + * Per-protocol enablement lives in the binding's `config` jsonb: + * { "graphql": true, "rest": { "path": "/...", "methods": ["POST"] } } + * An absent `rest` key means REST is disabled for the binding. + * + * All queries run through req.constructive.withPgClient, which applies the + * request's pgSettings (role + jwt.claims.* incl. jwt.claims.api_id) in a + * transaction — RLS is fully enforced; no superuser or bypass path is used. + */ + +import { Logger } from '@pgpmjs/logger'; +import express, { Request, Response, Router } from 'express'; + +const log = new Logger('fn'); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +interface BindingRow { + config: RestBindingConfig | null; + task_identifier: string; + database_id: string; +} + +interface RestBindingConfig { + graphql?: boolean; + rest?: { + path?: string; + methods?: string[]; + }; +} + +interface InvocationRow { + id: string; + status: string; + payload: unknown; + result: unknown; + error: unknown; + created_at: string; + started_at: string | null; + completed_at: string | null; + duration_ms: number | null; +} + +/** Quote a trusted (metaschema-resolved) identifier for SQL interpolation. */ +const ident = (name: string): string => `"${name.replace(/"/g, '""')}"`; + +const notFound = (res: Response): void => { + res.status(404).json({ error: 'Not found' }); +}; + +async function handleInvoke(req: Request, res: Response): Promise { + const ctx = req.constructive; + if (!ctx?.api?.apiId) { + notFound(res); + return; + } + + const compute = await ctx.useModule('compute'); + if (!compute) { + notFound(res); + return; + } + + const { schemaName, bindingsTableName, definitionsTableName } = compute; + const alias = req.params.alias; + + try { + const binding = await ctx.withPgClient(async (client) => { + const { rows } = await client.query( + `SELECT b.config, d.task_identifier, d.database_id + FROM ${ident(schemaName)}.${ident(bindingsTableName)} b + JOIN ${ident(schemaName)}.${ident(definitionsTableName)} d ON d.id = b.function_definition_id + WHERE b.api_id = $1 AND b.alias = $2`, + [ctx.api.apiId, alias] + ); + return rows[0]; + }); + + // 404 when the binding doesn't exist, REST is disabled for the binding + // (absent `rest` config), or the HTTP method isn't allowed. + const rest = binding?.config?.rest; + if (!binding || !rest) { + notFound(res); + return; + } + const methods = (rest.methods ?? ['POST']).map((m) => m.toUpperCase()); + if (!methods.includes(req.method)) { + notFound(res); + return; + } + + // TODO: JSON-Schema (ajv) payload validation hook — intentionally deferred. + // When enabled it should validate req.body against the binding/function + // input schema here, before the invocation row is inserted. + const payload = req.body ?? {}; + + const invocationId = await ctx.withPgClient(async (client) => { + const { rows } = await client.query<{ id: string }>( + `INSERT INTO ${ident(compute.invocationsSchemaName)}.${ident(compute.invocationsTableName)} + (database_id, task_identifier, payload) + VALUES ($1, $2, $3::jsonb) + RETURNING id`, + [binding.database_id, binding.task_identifier, JSON.stringify(payload)] + ); + return rows[0].id; + }); + + res.status(202).json({ invocationId }); + } catch (err: any) { + if (err?.code === '42501') { + // insufficient_privilege — RLS rejected the insert for this caller + res.status(403).json({ error: 'Forbidden' }); + return; + } + log.error({ event: 'fn_invoke_failed', alias, requestId: req.requestId, error: err?.message }); + res.status(500).json({ error: 'Internal server error' }); + } +} + +async function handleGetInvocation(req: Request, res: Response): Promise { + const ctx = req.constructive; + if (!ctx?.api?.apiId) { + notFound(res); + return; + } + + const id = req.params.id; + if (!UUID_RE.test(id)) { + notFound(res); + return; + } + + const compute = await ctx.useModule('compute'); + if (!compute) { + notFound(res); + return; + } + + try { + const invocation = await ctx.withPgClient(async (client) => { + const { rows } = await client.query( + `SELECT id, status, result, error, created_at, started_at, completed_at, duration_ms + FROM ${ident(compute.invocationsSchemaName)}.${ident(compute.invocationsTableName)} + WHERE id = $1`, + [id] + ); + return rows[0]; + }); + + // RLS-filtered read: rows not visible to the caller simply aren't returned + if (!invocation) { + notFound(res); + return; + } + + res.status(200).json({ + id: invocation.id, + status: invocation.status, + result: invocation.result, + error: invocation.error, + createdAt: invocation.created_at, + startedAt: invocation.started_at, + completedAt: invocation.completed_at, + durationMs: invocation.duration_ms + }); + } catch (err: any) { + log.error({ event: 'fn_get_invocation_failed', id, requestId: req.requestId, error: err?.message }); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export function createFnRouter(): Router { + const router = Router(); + + router.use('/fn', express.json()); + router.get('/fn/invocations/:id', handleGetInvocation); + router.all('/fn/:alias', handleInvoke); + + return router; +} diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index 477e61288..2f32e15d3 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -236,6 +236,12 @@ const buildPreset = ( if (req.databaseId) { context['jwt.claims.database_id'] = req.databaseId; } + // API provenance — which API surface this request arrived through. + // Derived server-side from hostname -> services_public.domains -> api_id; + // never taken from client-supplied headers, body, or token payload. + if (req.api?.apiId) { + context['jwt.claims.api_id'] = req.api.apiId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index a88b48d9f..6f08fd702 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -27,6 +27,7 @@ import { createAuthenticateMiddleware } from './middleware/auth'; import { cors } from './middleware/cors'; import { errorHandler, notFoundHandler } from './middleware/error-handler'; import { favicon } from './middleware/favicon'; +import { createFnRouter } from './middleware/fn'; import { flush, flushService } from './middleware/flush'; import { graphile } from './middleware/graphile'; import { multipartBridge } from './middleware/multipart-bridge'; @@ -38,7 +39,7 @@ import { createRequestLogger } from './middleware/observability/request-logger'; import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createAgenticRouter } from 'agentic-server'; -import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context'; +import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; const log = new Logger('server'); @@ -164,7 +165,7 @@ class Server { app.use(requestLogger); app.use(api); app.use(authenticate); - app.use(createContextMiddleware({ pg: effectiveOpts.pg })); + app.use(createContextMiddleware({ pg: effectiveOpts.pg, loaders: createDefaultRegistry() })); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests @@ -200,6 +201,9 @@ class Server { // routes are handled without going through PostGraphile app.use(createAgenticRouter()); + // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id) + app.use(createFnRouter()); + app.use(graphile(effectiveOpts)); app.use(flush); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts new file mode 100644 index 000000000..b1f4ec3e0 --- /dev/null +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -0,0 +1,52 @@ +import { buildPgSettings } from '../src/pg-settings'; +import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; + +const api: ApiStructure = { + apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + dbname: 'testdb', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['public'], + apiModules: [], + domains: [], + databaseId: '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + isPublic: true +}; + +describe('buildPgSettings — jwt.claims.api_id provenance', () => { + it('sets jwt.claims.api_id from the resolved api for anonymous requests', () => { + const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['role']).toBe('anonymous'); + }); + + it('sets jwt.claims.api_id from the resolved api for authenticated requests', () => { + const token = { user_id: 'u1' } as ConstructiveAPIToken; + const settings = buildPgSettings({ api, token, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['role']).toBe('authenticated'); + expect(settings['jwt.claims.user_id']).toBe('u1'); + }); + + it('omits jwt.claims.api_id when the api has no apiId (non-API surface)', () => { + const settings = buildPgSettings({ + api: { ...api, apiId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.api_id']).toBeUndefined(); + }); + + it('is derived only from the resolved api, never from the token', () => { + const token = { + user_id: 'u1', + api_id: 'attacker-controlled' + } as unknown as ConstructiveAPIToken; + const settings = buildPgSettings({ api, token, requestId: 'r1' }); + + expect(settings['jwt.claims.api_id']).toBe(api.apiId); + }); +}); diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 02a6465ed..8ee713f80 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -45,6 +45,7 @@ export type { AuthSettings, BillingConfig, BuiltinModuleMap, + ComputeConfig, ConstructiveAPIToken, ConstructiveContext, CorsModuleData, @@ -88,6 +89,7 @@ export { agentChatLoader, authSettingsLoader, billingLoader, + computeLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, diff --git a/packages/express-context/src/loaders/compute.ts b/packages/express-context/src/loaders/compute.ts new file mode 100644 index 000000000..e93613a34 --- /dev/null +++ b/packages/express-context/src/loaders/compute.ts @@ -0,0 +1,65 @@ +/** + * Compute Module Loader + * + * Resolves per-database compute (functions) config from + * metaschema_modules_public.function_module and function_invocation_module. + * Returns the schema and table names for function definitions, API bindings, + * and invocations so REST routes can address the generated tables. + */ + +import type { ComputeConfig } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const COMPUTE_MODULE_SQL = ` + SELECT + fs.schema_name AS functions_schema_name, + fm.definitions_table_name, + ivs.schema_name AS invocations_schema_name, + ivm.invocations_table_name + FROM metaschema_modules_public.function_module fm + JOIN metaschema_public.schema fs ON fs.id = fm.schema_id + JOIN metaschema_modules_public.function_invocation_module ivm + ON ivm.database_id = fm.database_id AND ivm.scope = fm.scope + JOIN metaschema_public.schema ivs ON ivs.id = ivm.schema_id + WHERE fm.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface ComputeModuleRow { + functions_schema_name: string; + definitions_table_name: string; + invocations_schema_name: string; + invocations_table_name: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const computeLoader: ModuleLoader = createModuleLoader({ + name: 'compute', + ttlMs: 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + COMPUTE_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.functions_schema_name, + definitionsTableName: row.definitions_table_name, + // The bindings table is generated alongside the definitions table + // (see metaschema_generators.function_module). + bindingsTableName: row.definitions_table_name.replace(/_definitions$/, '_api_bindings'), + invocationsSchemaName: row.invocations_schema_name, + invocationsTableName: row.invocations_table_name, + }; + }, +}); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index c79efe3e6..794d5d197 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -48,6 +48,7 @@ export { billingLoader } from './billing'; export { inferenceLogLoader } from './inference-log'; export { agentChatLoader } from './agent-chat'; export { llmLoader } from './llm'; +export { computeLoader } from './compute'; /** * Convenience: create a registry pre-loaded with all built-in loaders. @@ -63,6 +64,7 @@ import { billingLoader } from './billing'; import { inferenceLogLoader } from './inference-log'; import { agentChatLoader } from './agent-chat'; import { llmLoader } from './llm'; +import { computeLoader } from './compute'; export function createDefaultRegistry() { const registry = createLoaderRegistry(); @@ -76,5 +78,6 @@ export function createDefaultRegistry() { registry.register(inferenceLogLoader); registry.register(agentChatLoader); registry.register(llmLoader); + registry.register(computeLoader); return registry; } diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index ff226e593..86788474a 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -56,6 +56,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.database_id'] = api.databaseId; } + // API provenance — which API surface this request arrived through. + // Derived server-side from hostname -> services_public.domains -> api_id; + // never taken from client-supplied headers, body, or token payload. + if (api.apiId) { + settings['jwt.claims.api_id'] = api.apiId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 39e0a79c5..3d3150b56 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -154,6 +154,14 @@ export interface AgentChatConfig { taskTableName: string | null; } +export interface ComputeConfig { + schemaName: string; + definitionsTableName: string; + bindingsTableName: string; + invocationsSchemaName: string; + invocationsTableName: string; +} + export interface LlmConfig { embeddingProvider: string; embeddingModel: string; @@ -188,6 +196,7 @@ export interface BuiltinModuleMap { inferenceLog: InferenceLogConfig; agentChat: AgentChatConfig; llm: LlmConfig; + compute: ComputeConfig; } // ─── Constructive Context ───────────────────────────────────────────────────