@constructive-io/graphql-server 4.36.0 → 4.36.2

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.
@@ -1,341 +0,0 @@
1
- import { Logger } from '@pgpmjs/logger';
2
- import fs from 'fs';
3
- import multer from 'multer';
4
- import os from 'os';
5
- import { QuoteUtils } from '@pgsql/quotes';
6
- import { getPgPool } from 'pg-cache';
7
- import pgQueryContext from 'pg-query-context';
8
- import { streamToStorage } from 'graphile-settings';
9
- import './types';
10
- const uploadLog = new Logger('upload');
11
- const authLog = new Logger('upload-auth');
12
- const envFileSize = process.env.MAX_UPLOAD_FILE_SIZE
13
- ? parseInt(process.env.MAX_UPLOAD_FILE_SIZE, 10)
14
- : NaN;
15
- const MAX_FILE_SIZE = envFileSize > 0 ? envFileSize : 10 * 1024 * 1024;
16
- const BLOCKED_MIME_TYPES = new Set([
17
- 'application/x-executable',
18
- 'application/x-sharedlib',
19
- 'application/x-mach-binary',
20
- 'application/x-dosexec',
21
- 'text/html',
22
- 'application/xhtml+xml',
23
- 'application/javascript',
24
- 'text/javascript'
25
- ]);
26
- const parseFile = multer({
27
- storage: multer.diskStorage({
28
- destination: os.tmpdir(),
29
- filename: (_req, _file, cb) => {
30
- const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
31
- cb(null, `upload-${uniqueSuffix}.tmp`);
32
- },
33
- }),
34
- limits: { fileSize: MAX_FILE_SIZE },
35
- }).single('file');
36
- const parseFileWithErrors = (req, res, next) => {
37
- parseFile(req, res, (err) => {
38
- if (!err)
39
- return next();
40
- if (err.code === 'LIMIT_FILE_SIZE') {
41
- return res.status(413).json({ error: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)} MB` });
42
- }
43
- if (err.code === 'LIMIT_UNEXPECTED_FILE') {
44
- return res.status(400).json({ error: 'Unexpected file field. Send a single file as "file".' });
45
- }
46
- return res.status(400).json({ error: 'File upload failed' });
47
- });
48
- };
49
- const RLS_MODULE_BY_DATABASE_ID_SQL = `
50
- SELECT am.data
51
- FROM services_public.api_modules am
52
- JOIN services_public.apis a ON am.api_id = a.id
53
- WHERE am.name = 'rls_module' AND a.database_id = $1
54
- ORDER BY a.id
55
- LIMIT 1
56
- `;
57
- const RLS_MODULE_BY_API_ID_SQL = `
58
- SELECT data
59
- FROM services_public.api_modules
60
- WHERE api_id = $1 AND name = 'rls_module'
61
- LIMIT 1
62
- `;
63
- const RLS_MODULE_BY_DBNAME_SQL = `
64
- SELECT am.data
65
- FROM services_public.api_modules am
66
- JOIN services_public.apis a ON am.api_id = a.id
67
- WHERE am.name = 'rls_module' AND a.dbname = $1
68
- ORDER BY a.id
69
- LIMIT 1
70
- `;
71
- const RLS_SETTINGS_BY_DATABASE_ID_SQL = `
72
- SELECT
73
- auth_schema.schema_name AS authenticate_schema,
74
- role_schema.schema_name AS role_schema,
75
- auth_fn.name AS authenticate,
76
- auth_strict_fn.name AS authenticate_strict,
77
- role_fn.name AS current_role,
78
- role_id_fn.name AS current_role_id,
79
- ua_fn.name AS current_user_agent,
80
- ip_fn.name AS current_ip_address
81
- FROM services_public.rls_settings rs
82
- LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id
83
- LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id
84
- LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id
85
- LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id
86
- LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id
87
- LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id
88
- LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id
89
- LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id
90
- WHERE rs.database_id = $1
91
- LIMIT 1
92
- `;
93
- const RLS_SETTINGS_BY_DBNAME_SQL = `
94
- SELECT
95
- auth_schema.schema_name AS authenticate_schema,
96
- role_schema.schema_name AS role_schema,
97
- auth_fn.name AS authenticate,
98
- auth_strict_fn.name AS authenticate_strict,
99
- role_fn.name AS current_role,
100
- role_id_fn.name AS current_role_id,
101
- ua_fn.name AS current_user_agent,
102
- ip_fn.name AS current_ip_address
103
- FROM services_public.rls_settings rs
104
- JOIN services_public.apis a ON rs.database_id = a.database_id
105
- LEFT JOIN metaschema_public.schema auth_schema ON rs.authenticate_schema_id = auth_schema.id
106
- LEFT JOIN metaschema_public.schema role_schema ON rs.role_schema_id = role_schema.id
107
- LEFT JOIN metaschema_public.function auth_fn ON rs.authenticate_function_id = auth_fn.id
108
- LEFT JOIN metaschema_public.function auth_strict_fn ON rs.authenticate_strict_function_id = auth_strict_fn.id
109
- LEFT JOIN metaschema_public.function role_fn ON rs.current_role_function_id = role_fn.id
110
- LEFT JOIN metaschema_public.function role_id_fn ON rs.current_role_id_function_id = role_id_fn.id
111
- LEFT JOIN metaschema_public.function ua_fn ON rs.current_user_agent_function_id = ua_fn.id
112
- LEFT JOIN metaschema_public.function ip_fn ON rs.current_ip_address_function_id = ip_fn.id
113
- WHERE a.dbname = $1
114
- LIMIT 1
115
- `;
116
- const toRlsModule = (row) => {
117
- if (!row?.data)
118
- return undefined;
119
- const d = row.data;
120
- return {
121
- authenticate: d.authenticate,
122
- authenticateStrict: d.authenticate_strict,
123
- privateSchema: { schemaName: d.authenticate_schema },
124
- publicSchema: { schemaName: d.role_schema },
125
- currentRole: d.current_role,
126
- currentRoleId: d.current_role_id,
127
- currentIpAddress: d.current_ip_address,
128
- currentUserAgent: d.current_user_agent,
129
- };
130
- };
131
- const toRlsModuleFromSettings = (row) => {
132
- if (!row)
133
- return undefined;
134
- return {
135
- authenticate: row.authenticate,
136
- authenticateStrict: row.authenticate_strict,
137
- privateSchema: { schemaName: row.authenticate_schema },
138
- publicSchema: { schemaName: row.role_schema },
139
- currentRole: row.current_role,
140
- currentRoleId: row.current_role_id,
141
- currentIpAddress: row.current_ip_address,
142
- currentUserAgent: row.current_user_agent,
143
- };
144
- };
145
- const getBearerToken = (authorization) => {
146
- if (!authorization)
147
- return null;
148
- const [authType, authToken] = authorization.split(' ');
149
- if (authType?.toLowerCase() !== 'bearer' || !authToken) {
150
- return null;
151
- }
152
- return authToken;
153
- };
154
- const queryRlsSettingsByDatabaseId = async (pool, databaseId) => {
155
- try {
156
- const result = await pool.query(RLS_SETTINGS_BY_DATABASE_ID_SQL, [databaseId]);
157
- return toRlsModuleFromSettings(result.rows[0] ?? null);
158
- }
159
- catch {
160
- return undefined;
161
- }
162
- };
163
- const queryRlsSettingsByDbname = async (pool, dbname) => {
164
- try {
165
- const result = await pool.query(RLS_SETTINGS_BY_DBNAME_SQL, [dbname]);
166
- return toRlsModuleFromSettings(result.rows[0] ?? null);
167
- }
168
- catch {
169
- return undefined;
170
- }
171
- };
172
- const queryRlsModuleByDatabaseId = async (pool, databaseId) => {
173
- const fromSettings = await queryRlsSettingsByDatabaseId(pool, databaseId);
174
- if (fromSettings)
175
- return fromSettings;
176
- const result = await pool.query(RLS_MODULE_BY_DATABASE_ID_SQL, [databaseId]);
177
- return toRlsModule(result.rows[0] ?? null);
178
- };
179
- const queryRlsModuleByApiId = async (pool, apiId) => {
180
- const result = await pool.query(RLS_MODULE_BY_API_ID_SQL, [apiId]);
181
- return toRlsModule(result.rows[0] ?? null);
182
- };
183
- const queryRlsModuleByDbname = async (pool, dbname) => {
184
- const fromSettings = await queryRlsSettingsByDbname(pool, dbname);
185
- if (fromSettings)
186
- return fromSettings;
187
- const result = await pool.query(RLS_MODULE_BY_DBNAME_SQL, [dbname]);
188
- return toRlsModule(result.rows[0] ?? null);
189
- };
190
- const resolveUploadRlsModule = async (opts, req) => {
191
- const api = req.api;
192
- if (!api)
193
- return undefined;
194
- // Use API-scoped RLS module when available (e.g., meta API).
195
- if (api.rlsModule) {
196
- return api.rlsModule;
197
- }
198
- const pool = getPgPool(opts.pg);
199
- if (api.apiId) {
200
- const byApiId = await queryRlsModuleByApiId(pool, api.apiId);
201
- if (byApiId)
202
- return byApiId;
203
- }
204
- if (api.databaseId) {
205
- const byDatabaseId = await queryRlsModuleByDatabaseId(pool, api.databaseId);
206
- if (byDatabaseId)
207
- return byDatabaseId;
208
- }
209
- if (api.dbname) {
210
- return queryRlsModuleByDbname(pool, api.dbname);
211
- }
212
- return undefined;
213
- };
214
- const authError = (res) => res.status(401).json({ error: 'Authentication required' });
215
- /**
216
- * Upload-specific authentication middleware.
217
- *
218
- * This middleware enforces strict auth semantics for `POST /upload` while
219
- * preserving existing GraphQL auth behavior for other routes.
220
- */
221
- export const createUploadAuthenticateMiddleware = (opts) => {
222
- return async (req, res, next) => {
223
- const api = req.api;
224
- if (!api) {
225
- res.status(500).send('Missing API info');
226
- return;
227
- }
228
- if (req.token?.user_id) {
229
- next();
230
- return;
231
- }
232
- const authToken = getBearerToken(req.headers.authorization);
233
- if (!authToken) {
234
- authError(res);
235
- return;
236
- }
237
- let rlsModule;
238
- try {
239
- rlsModule = await resolveUploadRlsModule(opts, req);
240
- }
241
- catch (error) {
242
- authLog.error('[upload-auth] Failed to resolve RLS module for upload route', error);
243
- authError(res);
244
- return;
245
- }
246
- if (!rlsModule) {
247
- authLog.info(`[upload-auth] No RLS module found for db=${api.dbname} databaseId=${api.databaseId ?? 'none'}`);
248
- authError(res);
249
- return;
250
- }
251
- const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate;
252
- const privateSchema = rlsModule.privateSchema?.schemaName;
253
- if (!authFn || !privateSchema) {
254
- authLog.warn(`[upload-auth] Missing auth function or private schema for db=${api.dbname}; strictAuth=${opts.server?.strictAuth ?? false}`);
255
- authError(res);
256
- return;
257
- }
258
- const pool = getPgPool({
259
- ...opts.pg,
260
- database: api.dbname,
261
- });
262
- const context = {};
263
- if (req.clientIp) {
264
- context['jwt.claims.ip_address'] = req.clientIp;
265
- }
266
- if (req.get('origin')) {
267
- context['jwt.claims.origin'] = req.get('origin');
268
- }
269
- if (req.get('User-Agent')) {
270
- context['jwt.claims.user_agent'] = req.get('User-Agent');
271
- }
272
- try {
273
- const result = await pgQueryContext({
274
- client: pool,
275
- context,
276
- query: `SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchema, authFn)}($1)`,
277
- variables: [authToken],
278
- });
279
- if (!result?.rowCount) {
280
- authError(res);
281
- return;
282
- }
283
- req.token = result.rows[0];
284
- if (!req.token?.user_id) {
285
- authError(res);
286
- return;
287
- }
288
- next();
289
- }
290
- catch (error) {
291
- authLog.warn('[upload-auth] Upload authentication failed', error);
292
- authError(res);
293
- }
294
- };
295
- };
296
- /**
297
- * REST file upload endpoint.
298
- *
299
- * Accepts a single file via multipart/form-data, streams it to S3/MinIO,
300
- * and returns file metadata. The frontend uses this in a two-step flow:
301
- *
302
- * 1. POST /upload -> { url, filename, mime, size }
303
- * 2. GraphQL mutation -> patch row with the returned metadata
304
- */
305
- export const uploadRoute = [
306
- parseFileWithErrors,
307
- (async (req, res, next) => {
308
- if (!req.token?.user_id) {
309
- return res.status(401).json({ error: 'Authentication required' });
310
- }
311
- if (!req.file) {
312
- return res.status(400).json({ error: 'No file provided. Send a "file" field.' });
313
- }
314
- if (req.file.mimetype && BLOCKED_MIME_TYPES.has(req.file.mimetype)) {
315
- fs.unlink(req.file.path, () => { });
316
- return res.status(415).json({ error: 'File type not allowed' });
317
- }
318
- try {
319
- const readStream = fs.createReadStream(req.file.path);
320
- const result = await streamToStorage(readStream, req.file.originalname);
321
- uploadLog.debug(`[upload] Uploaded file for user=${req.token.user_id} filename=${req.file.originalname} mime=${result.mime} size=${req.file.size}`);
322
- res.json({
323
- url: result.url,
324
- filename: result.filename,
325
- mime: result.mime,
326
- size: req.file.size,
327
- });
328
- }
329
- catch (error) {
330
- uploadLog.error('[upload] Upload processing failed', error);
331
- if (!res.headersSent) {
332
- res.status(500).json({ error: 'Upload processing failed' });
333
- }
334
- }
335
- finally {
336
- if (req.file?.path) {
337
- fs.unlink(req.file.path, () => { });
338
- }
339
- }
340
- }),
341
- ];
@@ -1,27 +0,0 @@
1
- /**
2
- * LLM API Router
3
- *
4
- * Express router providing REST streaming endpoints for AI agent conversations.
5
- * Uses the agent tables (agent_thread, agent_message) discovered from the
6
- * agent_chat_module config table at runtime.
7
- *
8
- * Hybrid architecture:
9
- * - GraphQL handles CRUD (threads, messages, tasks) via PostGraphile
10
- * - REST handles SSE streaming for chat completions (what GraphQL can't do)
11
- *
12
- * Routes (entity-scoped):
13
- * POST /v1/orgs/:entity_id/threads → create thread
14
- * POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message + stream response
15
- *
16
- * Routes (global — bills to actor_id from JWT):
17
- * POST /v1/threads → create thread (entity_id = user_id)
18
- * POST /v1/threads/:thread_id/messages → send message + stream response
19
- *
20
- * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
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.
25
- */
26
- import { Router } from 'express';
27
- export declare function createLlmApiRouter(): Router;