@constructive-io/graphql-server 4.36.1 → 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.
- package/esm/index.js +0 -1
- package/esm/server.js +2 -5
- package/index.d.ts +0 -1
- package/index.js +1 -3
- package/package.json +4 -9
- package/server.js +2 -5
- package/esm/middleware/llm-api.js +0 -539
- package/esm/middleware/upload.js +0 -341
- package/middleware/llm-api.d.ts +0 -27
- package/middleware/llm-api.js +0 -575
- package/middleware/upload.d.ts +0 -20
- package/middleware/upload.js +0 -348
package/esm/middleware/upload.js
DELETED
|
@@ -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
|
-
];
|
package/middleware/llm-api.d.ts
DELETED
|
@@ -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;
|