@constructive-io/graphql-server 4.33.0 → 4.34.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.
@@ -19,48 +19,17 @@
19
19
  *
20
20
  * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
21
21
  * Metering: check_billing_quota → LLM call → record_usage with real token counts
22
+ *
23
+ * Context: Uses `req.constructive` from @constructive-io/express-context
24
+ * for tenant-scoped database access, pgSettings, and withPgClient.
22
25
  */
23
26
  import express, { Router } from 'express';
24
27
  import { Logger } from '@pgpmjs/logger';
25
- import { getPgPool } from 'pg-cache';
26
28
  import { OllamaAdapter } from '@agentic-kit/ollama';
27
29
  import { ModuleConfigCache } from 'graphile-cache';
28
30
  import { getLlmEnvOptions, getAgentDiscovery, getLlmBillingConfig, } from 'graphile-llm';
29
31
  const log = new Logger('llm-api');
30
32
  // ─── Helpers ────────────────────────────────────────────────────────────────
31
- function getPgSettings(req) {
32
- const settings = {};
33
- if (req.token?.user_id) {
34
- settings['jwt.claims.user_id'] = req.token.user_id;
35
- settings['role'] = 'authenticated';
36
- }
37
- if (req.databaseId) {
38
- settings['jwt.claims.database_id'] = req.databaseId;
39
- }
40
- if (req.requestId) {
41
- settings['request.id'] = req.requestId;
42
- }
43
- return settings;
44
- }
45
- async function withRlsClient(pool, pgSettings, fn) {
46
- const client = await pool.connect();
47
- try {
48
- await client.query('BEGIN');
49
- for (const [key, value] of Object.entries(pgSettings)) {
50
- await client.query('SELECT set_config($1, $2, true)', [key, value]);
51
- }
52
- const result = await fn(client);
53
- await client.query('COMMIT');
54
- return result;
55
- }
56
- catch (err) {
57
- await client.query('ROLLBACK').catch(() => { });
58
- throw err;
59
- }
60
- finally {
61
- client.release();
62
- }
63
- }
64
33
  function resolveOllamaAdapter() {
65
34
  const { chat } = getLlmEnvOptions();
66
35
  if (chat.provider === 'ollama') {
@@ -73,9 +42,9 @@ function resolveOllamaAdapter() {
73
42
  return null;
74
43
  }
75
44
  // ─── Billing Helpers ────────────────────────────────────────────────────────
76
- async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
45
+ async function checkQuota(ctx, billing, entityId, meterSlug) {
77
46
  try {
78
- return await withRlsClient(pool, pgSettings, async (client) => {
47
+ return await ctx.withPgClient(async (client) => {
79
48
  const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
80
49
  const result = await client.query(sql, [meterSlug, entityId, 1]);
81
50
  return result.rows[0]?.allowed !== false;
@@ -87,9 +56,9 @@ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
87
56
  return true;
88
57
  }
89
58
  }
90
- async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amount, metadata) {
59
+ async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
91
60
  try {
92
- await withRlsClient(pool, pgSettings, async (client) => {
61
+ await ctx.withPgClient(async (client) => {
93
62
  const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
94
63
  await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
95
64
  });
@@ -99,11 +68,13 @@ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amoun
99
68
  log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
100
69
  }
101
70
  }
102
- async function resolveBilling(pool, pgSettings, databaseId) {
71
+ async function resolveBilling(ctx) {
72
+ if (!ctx.databaseId)
73
+ return null;
103
74
  try {
104
75
  let billing = null;
105
- await withRlsClient(pool, pgSettings, async (client) => {
106
- const entry = await getLlmBillingConfig(client, databaseId);
76
+ await ctx.withPgClient(async (client) => {
77
+ const entry = await getLlmBillingConfig(client, ctx.databaseId);
107
78
  billing = entry.billing;
108
79
  });
109
80
  return billing;
@@ -142,9 +113,9 @@ async function getInferenceLogInfo(pool, dbname) {
142
113
  inferenceLogCache.set(dbname, info);
143
114
  return info;
144
115
  }
145
- async function logInference(pool, pgSettings, logInfo, data) {
116
+ async function logInference(ctx, logInfo, data) {
146
117
  try {
147
- await withRlsClient(pool, pgSettings, async (client) => {
118
+ await ctx.withPgClient(async (client) => {
148
119
  await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
149
120
  (entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
150
121
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
@@ -169,31 +140,29 @@ async function logInference(pool, pgSettings, logInfo, data) {
169
140
  }
170
141
  // ─── Route Handlers ─────────────────────────────────────────────────────────
171
142
  async function handleCreateThread(req, res, entityId) {
172
- if (!req.token?.user_id) {
143
+ const ctx = req.constructive;
144
+ if (!ctx?.userId) {
173
145
  res.status(401).json({ error: 'Authentication required' });
174
146
  return;
175
147
  }
176
- const dbname = req.api?.dbname;
177
- if (!dbname) {
148
+ if (!ctx.api.dbname) {
178
149
  res.status(400).json({ error: 'Database not resolved' });
179
150
  return;
180
151
  }
181
- const pool = getPgPool({ database: dbname });
182
- const discovery = await getAgentDiscovery(pool, dbname);
152
+ const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
183
153
  if (!discovery?.thread) {
184
154
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
185
155
  return;
186
156
  }
187
157
  const body = req.body || {};
188
158
  const { thread } = discovery;
189
- const pgSettings = getPgSettings(req);
190
- const result = await withRlsClient(pool, pgSettings, async (client) => {
159
+ const result = await ctx.withPgClient(async (client) => {
191
160
  const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
192
161
  (entity_id, owner_id, mode, model, system_prompt, title)
193
162
  VALUES ($1, $2, $3, $4, $5, $6)
194
163
  RETURNING id, mode, model, system_prompt, status, created_at`, [
195
164
  entityId,
196
- req.token.user_id,
165
+ ctx.userId,
197
166
  body.mode ?? 'ask',
198
167
  body.model ?? null,
199
168
  body.system_prompt ?? null,
@@ -211,17 +180,16 @@ async function handleCreateThread(req, res, entityId) {
211
180
  });
212
181
  }
213
182
  async function handleSendMessage(req, res, entityId) {
214
- if (!req.token?.user_id) {
183
+ const ctx = req.constructive;
184
+ if (!ctx?.userId) {
215
185
  res.status(401).json({ error: 'Authentication required' });
216
186
  return;
217
187
  }
218
- const dbname = req.api?.dbname;
219
- if (!dbname) {
188
+ if (!ctx.api.dbname) {
220
189
  res.status(400).json({ error: 'Database not resolved' });
221
190
  return;
222
191
  }
223
- const pool = getPgPool({ database: dbname });
224
- const discovery = await getAgentDiscovery(pool, dbname);
192
+ const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
225
193
  if (!discovery?.thread || !discovery?.message) {
226
194
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
227
195
  return;
@@ -232,12 +200,10 @@ async function handleSendMessage(req, res, entityId) {
232
200
  return;
233
201
  }
234
202
  const { thread, message: msgTable } = discovery;
235
- const pgSettings = getPgSettings(req);
236
203
  const threadId = req.params.thread_id;
237
- const userId = req.token.user_id;
238
- const databaseId = req.databaseId;
204
+ const userId = ctx.userId;
239
205
  // 1. Verify thread exists and user owns it (RLS enforced)
240
- const threadRow = await withRlsClient(pool, pgSettings, async (client) => {
206
+ const threadRow = await ctx.withPgClient(async (client) => {
241
207
  const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
242
208
  FROM "${thread.schemaName}"."${thread.tableName}"
243
209
  WHERE id = $1`, [threadId]);
@@ -248,10 +214,8 @@ async function handleSendMessage(req, res, entityId) {
248
214
  return;
249
215
  }
250
216
  // 2. Resolve billing config + inference log discovery
251
- const billing = databaseId
252
- ? await resolveBilling(pool, pgSettings, databaseId)
253
- : null;
254
- const inferenceLog = await getInferenceLogInfo(pool, dbname);
217
+ const billing = await resolveBilling(ctx);
218
+ const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
255
219
  const ollama = resolveOllamaAdapter();
256
220
  if (!ollama) {
257
221
  res.status(503).json({ error: 'No LLM provider configured' });
@@ -260,7 +224,7 @@ async function handleSendMessage(req, res, entityId) {
260
224
  const model = body.model ?? threadRow.model ?? ollama.model;
261
225
  const meterSlug = model;
262
226
  if (billing) {
263
- const allowed = await checkQuota(pool, pgSettings, billing, entityId, meterSlug);
227
+ const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
264
228
  if (!allowed) {
265
229
  res.status(429).json({
266
230
  error: 'Token quota exceeded',
@@ -271,7 +235,7 @@ async function handleSendMessage(req, res, entityId) {
271
235
  }
272
236
  }
273
237
  // 3. Persist user message(s)
274
- await withRlsClient(pool, pgSettings, async (client) => {
238
+ await ctx.withPgClient(async (client) => {
275
239
  for (const msg of body.messages) {
276
240
  if (msg.role === 'user') {
277
241
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
@@ -286,7 +250,7 @@ async function handleSendMessage(req, res, entityId) {
286
250
  }
287
251
  });
288
252
  // 4. Load full thread history for context
289
- const history = await withRlsClient(pool, pgSettings, async (client) => {
253
+ const history = await ctx.withPgClient(async (client) => {
290
254
  const { rows } = await client.query(`SELECT author_role, parts, created_at
291
255
  FROM "${msgTable.schemaName}"."${msgTable.tableName}"
292
256
  WHERE thread_id = $1
@@ -372,7 +336,7 @@ async function handleSendMessage(req, res, entityId) {
372
336
  res.end();
373
337
  // 6. Persist assistant message with model (fire-and-forget)
374
338
  if (content) {
375
- withRlsClient(pool, pgSettings, async (client) => {
339
+ ctx.withPgClient(async (client) => {
376
340
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
377
341
  (thread_id, owner_id, entity_id, author_role, parts, model)
378
342
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
@@ -388,7 +352,7 @@ async function handleSendMessage(req, res, entityId) {
388
352
  }
389
353
  // 7. Record billing usage (fire-and-forget)
390
354
  if (billing && usage.totalTokens > 0) {
391
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, usage.totalTokens, {
355
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
392
356
  input_tokens: usage.input,
393
357
  output_tokens: usage.output,
394
358
  cache_read_tokens: usage.cacheRead,
@@ -400,7 +364,7 @@ async function handleSendMessage(req, res, entityId) {
400
364
  }
401
365
  // 8. Inference log (fire-and-forget)
402
366
  if (inferenceLog) {
403
- logInference(pool, pgSettings, inferenceLog, {
367
+ logInference(ctx, inferenceLog, {
404
368
  entityId,
405
369
  actorId: userId,
406
370
  model,
@@ -456,7 +420,7 @@ async function handleSendMessage(req, res, entityId) {
456
420
  totalTokens: result.usage.totalTokens,
457
421
  };
458
422
  // Persist assistant message with model
459
- await withRlsClient(pool, pgSettings, async (client) => {
423
+ await ctx.withPgClient(async (client) => {
460
424
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
461
425
  (thread_id, owner_id, entity_id, author_role, parts, model)
462
426
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
@@ -469,7 +433,7 @@ async function handleSendMessage(req, res, entityId) {
469
433
  });
470
434
  // Record billing usage
471
435
  if (billing && usage.totalTokens > 0) {
472
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, usage.totalTokens, {
436
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
473
437
  input_tokens: usage.input,
474
438
  output_tokens: usage.output,
475
439
  cache_read_tokens: usage.cacheRead,
@@ -481,7 +445,7 @@ async function handleSendMessage(req, res, entityId) {
481
445
  }
482
446
  // Inference log
483
447
  if (inferenceLog) {
484
- logInference(pool, pgSettings, inferenceLog, {
448
+ logInference(ctx, inferenceLog, {
485
449
  entityId,
486
450
  actorId: userId,
487
451
  model,
@@ -541,7 +505,7 @@ export function createLlmApiRouter() {
541
505
  // ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
542
506
  router.post('/v1/threads', async (req, res) => {
543
507
  try {
544
- const userId = req.token?.user_id;
508
+ const userId = req.constructive?.userId;
545
509
  if (!userId) {
546
510
  res.status(401).json({ error: 'Authentication required' });
547
511
  return;
@@ -557,7 +521,7 @@ export function createLlmApiRouter() {
557
521
  });
558
522
  router.post('/v1/threads/:thread_id/messages', async (req, res) => {
559
523
  try {
560
- const userId = req.token?.user_id;
524
+ const userId = req.constructive?.userId;
561
525
  if (!userId) {
562
526
  res.status(401).json({ error: 'Authentication required' });
563
527
  return;
package/esm/server.js CHANGED
@@ -28,6 +28,7 @@ import { createCaptchaMiddleware } from './middleware/captcha';
28
28
  import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
29
29
  import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
30
30
  import { createLlmApiRouter } from './middleware/llm-api';
31
+ import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context';
31
32
  import { startDebugSampler } from './diagnostics/debug-sampler';
32
33
  const log = new Logger('server');
33
34
  /**
@@ -135,10 +136,12 @@ class Server {
135
136
  app.use('/graphql', multipartBridge);
136
137
  app.use(parseDomains());
137
138
  app.use(requestIp.mw());
139
+ app.use(requestIdMiddleware());
138
140
  app.use(requestLogger);
139
141
  app.use(api);
140
142
  app.post('/upload', uploadAuthenticate, ...uploadRoute);
141
143
  app.use(authenticate);
144
+ app.use(createContextMiddleware({ pg: effectiveOpts.pg }));
142
145
  app.use(createCaptchaMiddleware());
143
146
  // CSRF protection for cookie-authenticated requests
144
147
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests