@constructive-io/graphql-server 4.41.1 → 4.42.1
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/esm/middleware/fn.js +191 -0
- package/esm/middleware/graphile.js +32 -3
- package/esm/server.js +5 -2
- package/middleware/fn.d.ts +21 -0
- package/middleware/fn.js +227 -0
- package/middleware/graphile.js +32 -3
- package/package.json +9 -7
- package/server.js +4 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fn — REST function invocation routes
|
|
3
|
+
*
|
|
4
|
+
* POST /fn/:alias → invoke a function bound to this API (202 { invocationId })
|
|
5
|
+
* GET /fn/invocations/:id → read invocation status/result
|
|
6
|
+
*
|
|
7
|
+
* Routing is per-API: bindings are looked up by (api_id, alias) across the
|
|
8
|
+
* bindings tables of every provisioned function-module scope, where api_id
|
|
9
|
+
* comes from the server-side domain resolution (req.constructive.api.apiId)
|
|
10
|
+
* — never from client input. RLS on the underlying tables governs access.
|
|
11
|
+
*
|
|
12
|
+
* Per-protocol enablement lives in the binding's `config` jsonb:
|
|
13
|
+
* { "graphql": true, "rest": { "path": "/...", "methods": ["POST"] } }
|
|
14
|
+
* An absent `rest` key means REST is disabled for the binding.
|
|
15
|
+
*
|
|
16
|
+
* All queries run through req.constructive.withPgClient, which applies the
|
|
17
|
+
* request's pgSettings (role + jwt.claims.* incl. jwt.claims.api_id) in a
|
|
18
|
+
* transaction — RLS is fully enforced; no superuser or bypass path is used.
|
|
19
|
+
*/
|
|
20
|
+
import { Logger } from '@pgpmjs/logger';
|
|
21
|
+
import { isUuid } from '@pgpmjs/server-utils';
|
|
22
|
+
import { QueryBuilder } from '@constructive-io/query-builder';
|
|
23
|
+
import express, { Router } from 'express';
|
|
24
|
+
const log = new Logger('fn');
|
|
25
|
+
const notFound = (res) => {
|
|
26
|
+
res.status(404).json({ error: 'Not found' });
|
|
27
|
+
};
|
|
28
|
+
async function handleInvoke(req, res) {
|
|
29
|
+
const ctx = req.constructive;
|
|
30
|
+
if (!ctx?.api?.apiId) {
|
|
31
|
+
notFound(res);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const compute = await ctx.useModule('compute');
|
|
35
|
+
if (!compute?.modules.length) {
|
|
36
|
+
notFound(res);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const alias = req.params.alias;
|
|
40
|
+
try {
|
|
41
|
+
// Resolve the alias across every function-module scope's bindings table.
|
|
42
|
+
const resolved = await ctx.withPgClient(async (client) => {
|
|
43
|
+
const matches = [];
|
|
44
|
+
for (const module of compute.modules) {
|
|
45
|
+
// Binding lookup carries everything the invocation insert needs:
|
|
46
|
+
// the binding id (api_binding_id), the definition it points at
|
|
47
|
+
// (function_definition_id), the resolved task_identifier, and config.
|
|
48
|
+
const { text, values } = new QueryBuilder()
|
|
49
|
+
.schema(module.schemaName)
|
|
50
|
+
.table(module.bindingsTableName, 'b')
|
|
51
|
+
.select(['b.id', 'b.function_definition_id', 'b.config', 'd.task_identifier'])
|
|
52
|
+
.innerJoin(module.definitionsTableName, 'b.function_definition_id', '=', 'd.id', {
|
|
53
|
+
schema: module.schemaName,
|
|
54
|
+
alias: 'd',
|
|
55
|
+
})
|
|
56
|
+
.where('b.api_id', '=', ctx.api.apiId)
|
|
57
|
+
.where('b.alias', '=', alias)
|
|
58
|
+
.build();
|
|
59
|
+
const { rows } = await client.query(text, values);
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
matches.push({ binding: row, module });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return matches;
|
|
65
|
+
});
|
|
66
|
+
if (resolved.length > 1) {
|
|
67
|
+
// Same alias bound to this API from more than one scope — ambiguous.
|
|
68
|
+
res.status(409).json({ error: `Alias "${alias}" is ambiguous for this API` });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const binding = resolved[0]?.binding;
|
|
72
|
+
// 404 when the binding doesn't exist, REST is disabled for the binding
|
|
73
|
+
// (absent `rest` config), or the HTTP method isn't allowed.
|
|
74
|
+
const rest = binding?.config?.rest;
|
|
75
|
+
if (!binding || !rest) {
|
|
76
|
+
notFound(res);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const methods = (rest.methods ?? ['POST']).map((m) => m.toUpperCase());
|
|
80
|
+
if (!methods.includes(req.method)) {
|
|
81
|
+
notFound(res);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// TODO: JSON-Schema (ajv) payload validation hook — intentionally deferred.
|
|
85
|
+
// When enabled it should validate req.body against the binding/function
|
|
86
|
+
// input schema here, before the invocation row is inserted.
|
|
87
|
+
const payload = req.body ?? {};
|
|
88
|
+
const { invocationsSchemaName, invocationsTableName, invocationsEntityField } = resolved[0].module;
|
|
89
|
+
// API-channel provenance: set both the definition and the binding the
|
|
90
|
+
// invocation came through, at status 'pending'. The database's AFTER
|
|
91
|
+
// INSERT enqueue trigger schedules the job — the server never enqueues.
|
|
92
|
+
const insertData = {
|
|
93
|
+
task_identifier: binding.task_identifier,
|
|
94
|
+
function_definition_id: binding.function_definition_id,
|
|
95
|
+
api_binding_id: binding.id,
|
|
96
|
+
status: 'pending',
|
|
97
|
+
payload: JSON.stringify(payload),
|
|
98
|
+
};
|
|
99
|
+
// Scope-key column driven by the module's recorded entity_field: set for
|
|
100
|
+
// the database scope (database_id), absent for global scopes. Never a
|
|
101
|
+
// switch on scope name.
|
|
102
|
+
if (invocationsEntityField) {
|
|
103
|
+
insertData[invocationsEntityField] = ctx.api.databaseId ?? ctx.databaseId;
|
|
104
|
+
}
|
|
105
|
+
const { text, values } = new QueryBuilder()
|
|
106
|
+
.schema(invocationsSchemaName)
|
|
107
|
+
.table(invocationsTableName)
|
|
108
|
+
.insert(insertData)
|
|
109
|
+
.returning(['id'])
|
|
110
|
+
.build();
|
|
111
|
+
const invocationId = await ctx.withPgClient(async (client) => {
|
|
112
|
+
const { rows } = await client.query(text, values);
|
|
113
|
+
return rows[0].id;
|
|
114
|
+
});
|
|
115
|
+
res.status(202).json({ invocationId });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err?.code === '42501') {
|
|
119
|
+
// insufficient_privilege — RLS rejected the insert for this caller
|
|
120
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
log.error({ event: 'fn_invoke_failed', alias, requestId: req.requestId, error: err?.message });
|
|
124
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function handleGetInvocation(req, res) {
|
|
128
|
+
const ctx = req.constructive;
|
|
129
|
+
if (!ctx?.api?.apiId) {
|
|
130
|
+
notFound(res);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const id = req.params.id;
|
|
134
|
+
if (!isUuid(id)) {
|
|
135
|
+
notFound(res);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const compute = await ctx.useModule('compute');
|
|
139
|
+
if (!compute?.modules.length) {
|
|
140
|
+
notFound(res);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const invocation = await ctx.withPgClient(async (client) => {
|
|
145
|
+
// Invocation tables are per-scope; search each distinct one.
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const module of compute.modules) {
|
|
148
|
+
const key = `${module.invocationsSchemaName}.${module.invocationsTableName}`;
|
|
149
|
+
if (seen.has(key))
|
|
150
|
+
continue;
|
|
151
|
+
seen.add(key);
|
|
152
|
+
const { text, values } = new QueryBuilder()
|
|
153
|
+
.schema(module.invocationsSchemaName)
|
|
154
|
+
.table(module.invocationsTableName)
|
|
155
|
+
.select(['id', 'status', 'result', 'error', 'created_at', 'started_at', 'completed_at', 'duration_ms'])
|
|
156
|
+
.where('id', '=', id)
|
|
157
|
+
.build();
|
|
158
|
+
const { rows } = await client.query(text, values);
|
|
159
|
+
if (rows[0])
|
|
160
|
+
return rows[0];
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
});
|
|
164
|
+
// RLS-filtered read: rows not visible to the caller simply aren't returned
|
|
165
|
+
if (!invocation) {
|
|
166
|
+
notFound(res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.status(200).json({
|
|
170
|
+
id: invocation.id,
|
|
171
|
+
status: invocation.status,
|
|
172
|
+
result: invocation.result,
|
|
173
|
+
error: invocation.error,
|
|
174
|
+
createdAt: invocation.created_at,
|
|
175
|
+
startedAt: invocation.started_at,
|
|
176
|
+
completedAt: invocation.completed_at,
|
|
177
|
+
durationMs: invocation.duration_ms
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
log.error({ event: 'fn_get_invocation_failed', id, requestId: req.requestId, error: err?.message });
|
|
182
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function createFnRouter() {
|
|
186
|
+
const router = Router();
|
|
187
|
+
router.use('/fn', express.json());
|
|
188
|
+
router.get('/fn/invocations/:id', handleGetInvocation);
|
|
189
|
+
router.all('/fn/:alias', handleInvoke);
|
|
190
|
+
return router;
|
|
191
|
+
}
|
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { getNodeEnv } from '@pgpmjs/env';
|
|
3
3
|
import { Logger } from '@pgpmjs/logger';
|
|
4
4
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
5
|
+
import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
5
6
|
import { createConstructivePreset, makePgService } from 'graphile-settings';
|
|
6
7
|
import { getPgPool } from 'pg-cache';
|
|
7
8
|
import { getPgEnvOptions } from 'pg-env';
|
|
@@ -184,10 +185,31 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
184
185
|
* plugin preset. Without settings the default preset is used
|
|
185
186
|
* (everything on except aggregates).
|
|
186
187
|
*/
|
|
187
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
188
|
+
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
188
189
|
return {
|
|
189
190
|
extends: [createConstructivePreset(databaseSettings)],
|
|
190
|
-
plugins: [
|
|
191
|
+
plugins: [
|
|
192
|
+
AuthCookiePlugin,
|
|
193
|
+
// Only registered when the compute module is provisioned for this
|
|
194
|
+
// database — all schema/table names come from the constructive
|
|
195
|
+
// metaschema (express-context compute module loader); the plugin has
|
|
196
|
+
// no fallbacks or discovery of its own.
|
|
197
|
+
...(apiId && compute?.modules.length
|
|
198
|
+
? [
|
|
199
|
+
createFunctionBindingsPlugin({
|
|
200
|
+
apiId,
|
|
201
|
+
modules: compute.modules.map((m) => ({
|
|
202
|
+
computeSchema: m.schemaName,
|
|
203
|
+
bindingsTable: m.bindingsTableName,
|
|
204
|
+
definitionsTable: m.definitionsTableName,
|
|
205
|
+
invocationsSchema: m.invocationsSchemaName,
|
|
206
|
+
invocationsTable: m.invocationsTableName,
|
|
207
|
+
invocationsEntityField: m.invocationsEntityField,
|
|
208
|
+
})),
|
|
209
|
+
}),
|
|
210
|
+
]
|
|
211
|
+
: []),
|
|
212
|
+
],
|
|
191
213
|
pgServices: [
|
|
192
214
|
makePgService({
|
|
193
215
|
pool,
|
|
@@ -211,6 +233,12 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
|
211
233
|
if (req.databaseId) {
|
|
212
234
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
213
235
|
}
|
|
236
|
+
// API provenance — which API surface this request arrived through.
|
|
237
|
+
// Derived server-side from hostname -> services_public.domains -> api_id;
|
|
238
|
+
// never taken from client-supplied headers, body, or token payload.
|
|
239
|
+
if (req.api?.apiId) {
|
|
240
|
+
context['jwt.claims.api_id'] = req.api.apiId;
|
|
241
|
+
}
|
|
214
242
|
if (req.clientIp) {
|
|
215
243
|
context['jwt.claims.ip_address'] = req.clientIp;
|
|
216
244
|
}
|
|
@@ -333,7 +361,8 @@ export const graphile = (opts) => {
|
|
|
333
361
|
// properly, preventing leaked connections during database teardown.
|
|
334
362
|
const pool = getPgPool(pgConfig);
|
|
335
363
|
// Create promise and store in in-flight map BEFORE try block
|
|
336
|
-
const
|
|
364
|
+
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
365
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
337
366
|
const creationPromise = observeGraphileBuild({
|
|
338
367
|
cacheKey: key,
|
|
339
368
|
serviceKey: key,
|
package/esm/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createAuthenticateMiddleware } from './middleware/auth';
|
|
|
16
16
|
import { cors } from './middleware/cors';
|
|
17
17
|
import { errorHandler, notFoundHandler } from './middleware/error-handler';
|
|
18
18
|
import { favicon } from './middleware/favicon';
|
|
19
|
+
import { createFnRouter } from './middleware/fn';
|
|
19
20
|
import { flush, flushService } from './middleware/flush';
|
|
20
21
|
import { graphile } from './middleware/graphile';
|
|
21
22
|
import { multipartBridge } from './middleware/multipart-bridge';
|
|
@@ -27,7 +28,7 @@ import { createRequestLogger } from './middleware/observability/request-logger';
|
|
|
27
28
|
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
28
29
|
import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
29
30
|
import { createAgenticRouter } from 'agentic-server';
|
|
30
|
-
import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context';
|
|
31
|
+
import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context';
|
|
31
32
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
32
33
|
const log = new Logger('server');
|
|
33
34
|
/**
|
|
@@ -138,7 +139,7 @@ class Server {
|
|
|
138
139
|
app.use(requestLogger);
|
|
139
140
|
app.use(api);
|
|
140
141
|
app.use(authenticate);
|
|
141
|
-
app.use(createContextMiddleware({ pg: effectiveOpts.pg }));
|
|
142
|
+
app.use(createContextMiddleware({ pg: effectiveOpts.pg, loaders: createDefaultRegistry() }));
|
|
142
143
|
app.use(createCaptchaMiddleware());
|
|
143
144
|
// CSRF protection for cookie-authenticated requests
|
|
144
145
|
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|
|
@@ -171,6 +172,8 @@ class Server {
|
|
|
171
172
|
// LLM Agent REST API — mounted before graphile so SSE streaming
|
|
172
173
|
// routes are handled without going through PostGraphile
|
|
173
174
|
app.use(createAgenticRouter());
|
|
175
|
+
// REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
|
|
176
|
+
app.use(createFnRouter());
|
|
174
177
|
app.use(graphile(effectiveOpts));
|
|
175
178
|
app.use(flush);
|
|
176
179
|
// Error handling - MUST be LAST
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fn — REST function invocation routes
|
|
3
|
+
*
|
|
4
|
+
* POST /fn/:alias → invoke a function bound to this API (202 { invocationId })
|
|
5
|
+
* GET /fn/invocations/:id → read invocation status/result
|
|
6
|
+
*
|
|
7
|
+
* Routing is per-API: bindings are looked up by (api_id, alias) across the
|
|
8
|
+
* bindings tables of every provisioned function-module scope, where api_id
|
|
9
|
+
* comes from the server-side domain resolution (req.constructive.api.apiId)
|
|
10
|
+
* — never from client input. RLS on the underlying tables governs access.
|
|
11
|
+
*
|
|
12
|
+
* Per-protocol enablement lives in the binding's `config` jsonb:
|
|
13
|
+
* { "graphql": true, "rest": { "path": "/...", "methods": ["POST"] } }
|
|
14
|
+
* An absent `rest` key means REST is disabled for the binding.
|
|
15
|
+
*
|
|
16
|
+
* All queries run through req.constructive.withPgClient, which applies the
|
|
17
|
+
* request's pgSettings (role + jwt.claims.* incl. jwt.claims.api_id) in a
|
|
18
|
+
* transaction — RLS is fully enforced; no superuser or bypass path is used.
|
|
19
|
+
*/
|
|
20
|
+
import { Router } from 'express';
|
|
21
|
+
export declare function createFnRouter(): Router;
|
package/middleware/fn.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* fn — REST function invocation routes
|
|
4
|
+
*
|
|
5
|
+
* POST /fn/:alias → invoke a function bound to this API (202 { invocationId })
|
|
6
|
+
* GET /fn/invocations/:id → read invocation status/result
|
|
7
|
+
*
|
|
8
|
+
* Routing is per-API: bindings are looked up by (api_id, alias) across the
|
|
9
|
+
* bindings tables of every provisioned function-module scope, where api_id
|
|
10
|
+
* comes from the server-side domain resolution (req.constructive.api.apiId)
|
|
11
|
+
* — never from client input. RLS on the underlying tables governs access.
|
|
12
|
+
*
|
|
13
|
+
* Per-protocol enablement lives in the binding's `config` jsonb:
|
|
14
|
+
* { "graphql": true, "rest": { "path": "/...", "methods": ["POST"] } }
|
|
15
|
+
* An absent `rest` key means REST is disabled for the binding.
|
|
16
|
+
*
|
|
17
|
+
* All queries run through req.constructive.withPgClient, which applies the
|
|
18
|
+
* request's pgSettings (role + jwt.claims.* incl. jwt.claims.api_id) in a
|
|
19
|
+
* transaction — RLS is fully enforced; no superuser or bypass path is used.
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.createFnRouter = createFnRouter;
|
|
56
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
57
|
+
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
58
|
+
const query_builder_1 = require("@constructive-io/query-builder");
|
|
59
|
+
const express_1 = __importStar(require("express"));
|
|
60
|
+
const log = new logger_1.Logger('fn');
|
|
61
|
+
const notFound = (res) => {
|
|
62
|
+
res.status(404).json({ error: 'Not found' });
|
|
63
|
+
};
|
|
64
|
+
async function handleInvoke(req, res) {
|
|
65
|
+
const ctx = req.constructive;
|
|
66
|
+
if (!ctx?.api?.apiId) {
|
|
67
|
+
notFound(res);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const compute = await ctx.useModule('compute');
|
|
71
|
+
if (!compute?.modules.length) {
|
|
72
|
+
notFound(res);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const alias = req.params.alias;
|
|
76
|
+
try {
|
|
77
|
+
// Resolve the alias across every function-module scope's bindings table.
|
|
78
|
+
const resolved = await ctx.withPgClient(async (client) => {
|
|
79
|
+
const matches = [];
|
|
80
|
+
for (const module of compute.modules) {
|
|
81
|
+
// Binding lookup carries everything the invocation insert needs:
|
|
82
|
+
// the binding id (api_binding_id), the definition it points at
|
|
83
|
+
// (function_definition_id), the resolved task_identifier, and config.
|
|
84
|
+
const { text, values } = new query_builder_1.QueryBuilder()
|
|
85
|
+
.schema(module.schemaName)
|
|
86
|
+
.table(module.bindingsTableName, 'b')
|
|
87
|
+
.select(['b.id', 'b.function_definition_id', 'b.config', 'd.task_identifier'])
|
|
88
|
+
.innerJoin(module.definitionsTableName, 'b.function_definition_id', '=', 'd.id', {
|
|
89
|
+
schema: module.schemaName,
|
|
90
|
+
alias: 'd',
|
|
91
|
+
})
|
|
92
|
+
.where('b.api_id', '=', ctx.api.apiId)
|
|
93
|
+
.where('b.alias', '=', alias)
|
|
94
|
+
.build();
|
|
95
|
+
const { rows } = await client.query(text, values);
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
matches.push({ binding: row, module });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return matches;
|
|
101
|
+
});
|
|
102
|
+
if (resolved.length > 1) {
|
|
103
|
+
// Same alias bound to this API from more than one scope — ambiguous.
|
|
104
|
+
res.status(409).json({ error: `Alias "${alias}" is ambiguous for this API` });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const binding = resolved[0]?.binding;
|
|
108
|
+
// 404 when the binding doesn't exist, REST is disabled for the binding
|
|
109
|
+
// (absent `rest` config), or the HTTP method isn't allowed.
|
|
110
|
+
const rest = binding?.config?.rest;
|
|
111
|
+
if (!binding || !rest) {
|
|
112
|
+
notFound(res);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const methods = (rest.methods ?? ['POST']).map((m) => m.toUpperCase());
|
|
116
|
+
if (!methods.includes(req.method)) {
|
|
117
|
+
notFound(res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// TODO: JSON-Schema (ajv) payload validation hook — intentionally deferred.
|
|
121
|
+
// When enabled it should validate req.body against the binding/function
|
|
122
|
+
// input schema here, before the invocation row is inserted.
|
|
123
|
+
const payload = req.body ?? {};
|
|
124
|
+
const { invocationsSchemaName, invocationsTableName, invocationsEntityField } = resolved[0].module;
|
|
125
|
+
// API-channel provenance: set both the definition and the binding the
|
|
126
|
+
// invocation came through, at status 'pending'. The database's AFTER
|
|
127
|
+
// INSERT enqueue trigger schedules the job — the server never enqueues.
|
|
128
|
+
const insertData = {
|
|
129
|
+
task_identifier: binding.task_identifier,
|
|
130
|
+
function_definition_id: binding.function_definition_id,
|
|
131
|
+
api_binding_id: binding.id,
|
|
132
|
+
status: 'pending',
|
|
133
|
+
payload: JSON.stringify(payload),
|
|
134
|
+
};
|
|
135
|
+
// Scope-key column driven by the module's recorded entity_field: set for
|
|
136
|
+
// the database scope (database_id), absent for global scopes. Never a
|
|
137
|
+
// switch on scope name.
|
|
138
|
+
if (invocationsEntityField) {
|
|
139
|
+
insertData[invocationsEntityField] = ctx.api.databaseId ?? ctx.databaseId;
|
|
140
|
+
}
|
|
141
|
+
const { text, values } = new query_builder_1.QueryBuilder()
|
|
142
|
+
.schema(invocationsSchemaName)
|
|
143
|
+
.table(invocationsTableName)
|
|
144
|
+
.insert(insertData)
|
|
145
|
+
.returning(['id'])
|
|
146
|
+
.build();
|
|
147
|
+
const invocationId = await ctx.withPgClient(async (client) => {
|
|
148
|
+
const { rows } = await client.query(text, values);
|
|
149
|
+
return rows[0].id;
|
|
150
|
+
});
|
|
151
|
+
res.status(202).json({ invocationId });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err?.code === '42501') {
|
|
155
|
+
// insufficient_privilege — RLS rejected the insert for this caller
|
|
156
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
log.error({ event: 'fn_invoke_failed', alias, requestId: req.requestId, error: err?.message });
|
|
160
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function handleGetInvocation(req, res) {
|
|
164
|
+
const ctx = req.constructive;
|
|
165
|
+
if (!ctx?.api?.apiId) {
|
|
166
|
+
notFound(res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const id = req.params.id;
|
|
170
|
+
if (!(0, server_utils_1.isUuid)(id)) {
|
|
171
|
+
notFound(res);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const compute = await ctx.useModule('compute');
|
|
175
|
+
if (!compute?.modules.length) {
|
|
176
|
+
notFound(res);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const invocation = await ctx.withPgClient(async (client) => {
|
|
181
|
+
// Invocation tables are per-scope; search each distinct one.
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
for (const module of compute.modules) {
|
|
184
|
+
const key = `${module.invocationsSchemaName}.${module.invocationsTableName}`;
|
|
185
|
+
if (seen.has(key))
|
|
186
|
+
continue;
|
|
187
|
+
seen.add(key);
|
|
188
|
+
const { text, values } = new query_builder_1.QueryBuilder()
|
|
189
|
+
.schema(module.invocationsSchemaName)
|
|
190
|
+
.table(module.invocationsTableName)
|
|
191
|
+
.select(['id', 'status', 'result', 'error', 'created_at', 'started_at', 'completed_at', 'duration_ms'])
|
|
192
|
+
.where('id', '=', id)
|
|
193
|
+
.build();
|
|
194
|
+
const { rows } = await client.query(text, values);
|
|
195
|
+
if (rows[0])
|
|
196
|
+
return rows[0];
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
});
|
|
200
|
+
// RLS-filtered read: rows not visible to the caller simply aren't returned
|
|
201
|
+
if (!invocation) {
|
|
202
|
+
notFound(res);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
res.status(200).json({
|
|
206
|
+
id: invocation.id,
|
|
207
|
+
status: invocation.status,
|
|
208
|
+
result: invocation.result,
|
|
209
|
+
error: invocation.error,
|
|
210
|
+
createdAt: invocation.created_at,
|
|
211
|
+
startedAt: invocation.started_at,
|
|
212
|
+
completedAt: invocation.completed_at,
|
|
213
|
+
durationMs: invocation.duration_ms
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
log.error({ event: 'fn_get_invocation_failed', id, requestId: req.requestId, error: err?.message });
|
|
218
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function createFnRouter() {
|
|
222
|
+
const router = (0, express_1.Router)();
|
|
223
|
+
router.use('/fn', express_1.default.json());
|
|
224
|
+
router.get('/fn/invocations/:id', handleGetInvocation);
|
|
225
|
+
router.all('/fn/:alias', handleInvoke);
|
|
226
|
+
return router;
|
|
227
|
+
}
|
package/middleware/graphile.js
CHANGED
|
@@ -11,6 +11,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
|
11
11
|
const env_1 = require("@pgpmjs/env");
|
|
12
12
|
const logger_1 = require("@pgpmjs/logger");
|
|
13
13
|
const graphile_cache_1 = require("graphile-cache");
|
|
14
|
+
const graphile_function_bindings_1 = require("graphile-function-bindings");
|
|
14
15
|
const graphile_settings_1 = require("graphile-settings");
|
|
15
16
|
const pg_cache_1 = require("pg-cache");
|
|
16
17
|
const pg_env_1 = require("pg-env");
|
|
@@ -193,10 +194,31 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
193
194
|
* plugin preset. Without settings the default preset is used
|
|
194
195
|
* (everything on except aggregates).
|
|
195
196
|
*/
|
|
196
|
-
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
197
|
+
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId, compute) => {
|
|
197
198
|
return {
|
|
198
199
|
extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
|
|
199
|
-
plugins: [
|
|
200
|
+
plugins: [
|
|
201
|
+
auth_cookie_plugin_1.AuthCookiePlugin,
|
|
202
|
+
// Only registered when the compute module is provisioned for this
|
|
203
|
+
// database — all schema/table names come from the constructive
|
|
204
|
+
// metaschema (express-context compute module loader); the plugin has
|
|
205
|
+
// no fallbacks or discovery of its own.
|
|
206
|
+
...(apiId && compute?.modules.length
|
|
207
|
+
? [
|
|
208
|
+
(0, graphile_function_bindings_1.createFunctionBindingsPlugin)({
|
|
209
|
+
apiId,
|
|
210
|
+
modules: compute.modules.map((m) => ({
|
|
211
|
+
computeSchema: m.schemaName,
|
|
212
|
+
bindingsTable: m.bindingsTableName,
|
|
213
|
+
definitionsTable: m.definitionsTableName,
|
|
214
|
+
invocationsSchema: m.invocationsSchemaName,
|
|
215
|
+
invocationsTable: m.invocationsTableName,
|
|
216
|
+
invocationsEntityField: m.invocationsEntityField,
|
|
217
|
+
})),
|
|
218
|
+
}),
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
221
|
+
],
|
|
200
222
|
pgServices: [
|
|
201
223
|
(0, graphile_settings_1.makePgService)({
|
|
202
224
|
pool,
|
|
@@ -220,6 +242,12 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
|
220
242
|
if (req.databaseId) {
|
|
221
243
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
222
244
|
}
|
|
245
|
+
// API provenance — which API surface this request arrived through.
|
|
246
|
+
// Derived server-side from hostname -> services_public.domains -> api_id;
|
|
247
|
+
// never taken from client-supplied headers, body, or token payload.
|
|
248
|
+
if (req.api?.apiId) {
|
|
249
|
+
context['jwt.claims.api_id'] = req.api.apiId;
|
|
250
|
+
}
|
|
223
251
|
if (req.clientIp) {
|
|
224
252
|
context['jwt.claims.ip_address'] = req.clientIp;
|
|
225
253
|
}
|
|
@@ -342,7 +370,8 @@ const graphile = (opts) => {
|
|
|
342
370
|
// properly, preventing leaked connections during database teardown.
|
|
343
371
|
const pool = (0, pg_cache_1.getPgPool)(pgConfig);
|
|
344
372
|
// Create promise and store in in-flight map BEFORE try block
|
|
345
|
-
const
|
|
373
|
+
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
374
|
+
const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute);
|
|
346
375
|
const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
347
376
|
cacheKey: key,
|
|
348
377
|
serviceKey: key,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.42.1",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -42,17 +42,18 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@constructive-io/csrf": "^0.17.0",
|
|
45
|
-
"@constructive-io/express-context": "^0.
|
|
45
|
+
"@constructive-io/express-context": "^0.10.0",
|
|
46
46
|
"@constructive-io/graphql-env": "^3.15.0",
|
|
47
47
|
"@constructive-io/graphql-types": "^3.14.0",
|
|
48
|
+
"@constructive-io/query-builder": "^2.22.0",
|
|
48
49
|
"@constructive-io/s3-utils": "^2.20.0",
|
|
49
50
|
"@constructive-io/url-domains": "^2.19.0",
|
|
50
51
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
|
|
51
52
|
"@pgpmjs/env": "^2.27.0",
|
|
52
53
|
"@pgpmjs/logger": "^2.14.0",
|
|
53
|
-
"@pgpmjs/server-utils": "^3.15.
|
|
54
|
+
"@pgpmjs/server-utils": "^3.15.1",
|
|
54
55
|
"@pgpmjs/types": "^2.34.0",
|
|
55
|
-
"agentic-server": "0.9.
|
|
56
|
+
"agentic-server": "0.9.1",
|
|
56
57
|
"cors": "^2.8.6",
|
|
57
58
|
"deepmerge": "^4.3.1",
|
|
58
59
|
"express": "^5.2.1",
|
|
@@ -63,7 +64,8 @@
|
|
|
63
64
|
"graphile-build-pg": "5.0.2",
|
|
64
65
|
"graphile-cache": "^3.15.0",
|
|
65
66
|
"graphile-config": "1.0.1",
|
|
66
|
-
"graphile-
|
|
67
|
+
"graphile-function-bindings": "^0.2.1",
|
|
68
|
+
"graphile-settings": "^5.13.3",
|
|
67
69
|
"graphile-utils": "5.0.1",
|
|
68
70
|
"graphql": "16.13.0",
|
|
69
71
|
"graphql-upload": "^13.0.0",
|
|
@@ -85,10 +87,10 @@
|
|
|
85
87
|
"@types/pg": "^8.20.0",
|
|
86
88
|
"@types/request-ip": "^0.0.41",
|
|
87
89
|
"cookie-parser": "^1.4.7",
|
|
88
|
-
"graphile-test": "4.20.
|
|
90
|
+
"graphile-test": "4.20.2",
|
|
89
91
|
"makage": "^0.3.0",
|
|
90
92
|
"nodemon": "^3.1.14",
|
|
91
93
|
"ts-node": "^10.9.2"
|
|
92
94
|
},
|
|
93
|
-
"gitHead": "
|
|
95
|
+
"gitHead": "fe49621d08cbf93d3b75ee7bb9b0849580980369"
|
|
94
96
|
}
|
package/server.js
CHANGED
|
@@ -22,6 +22,7 @@ const auth_1 = require("./middleware/auth");
|
|
|
22
22
|
const cors_1 = require("./middleware/cors");
|
|
23
23
|
const error_handler_1 = require("./middleware/error-handler");
|
|
24
24
|
const favicon_1 = require("./middleware/favicon");
|
|
25
|
+
const fn_1 = require("./middleware/fn");
|
|
25
26
|
const flush_1 = require("./middleware/flush");
|
|
26
27
|
const graphile_1 = require("./middleware/graphile");
|
|
27
28
|
const multipart_bridge_1 = require("./middleware/multipart-bridge");
|
|
@@ -145,7 +146,7 @@ class Server {
|
|
|
145
146
|
app.use(requestLogger);
|
|
146
147
|
app.use(api);
|
|
147
148
|
app.use(authenticate);
|
|
148
|
-
app.use((0, express_context_1.createContextMiddleware)({ pg: effectiveOpts.pg }));
|
|
149
|
+
app.use((0, express_context_1.createContextMiddleware)({ pg: effectiveOpts.pg, loaders: (0, express_context_1.createDefaultRegistry)() }));
|
|
149
150
|
app.use((0, captcha_1.createCaptchaMiddleware)());
|
|
150
151
|
// CSRF protection for cookie-authenticated requests
|
|
151
152
|
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|
|
@@ -178,6 +179,8 @@ class Server {
|
|
|
178
179
|
// LLM Agent REST API — mounted before graphile so SSE streaming
|
|
179
180
|
// routes are handled without going through PostGraphile
|
|
180
181
|
app.use((0, agentic_server_1.createAgenticRouter)());
|
|
182
|
+
// REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
|
|
183
|
+
app.use((0, fn_1.createFnRouter)());
|
|
181
184
|
app.use((0, graphile_1.graphile)(effectiveOpts));
|
|
182
185
|
app.use(flush_1.flush);
|
|
183
186
|
// Error handling - MUST be LAST
|