@constructive-io/graphql-server 4.1.1 → 4.3.0

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 CHANGED
@@ -4,6 +4,7 @@ export * from './options';
4
4
  // Export middleware for use in testing packages
5
5
  export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api';
6
6
  export { createAuthenticateMiddleware } from './middleware/auth';
7
+ export { createUploadAuthenticateMiddleware } from './middleware/upload';
7
8
  export { cors } from './middleware/cors';
8
9
  export { graphile } from './middleware/graphile';
9
10
  export { flush, flushService } from './middleware/flush';
@@ -124,6 +124,7 @@ const toRlsModule = (row) => {
124
124
  };
125
125
  };
126
126
  const toApiStructure = (row, opts, rlsModuleRow) => ({
127
+ apiId: row.api_id,
127
128
  dbname: row.dbname || opts.pg?.database || '',
128
129
  anonRole: row.anon_role || 'anon',
129
130
  roleName: row.role_name || 'authenticated',
@@ -75,12 +75,11 @@ export const errorHandler = (err, req, res, _next) => {
75
75
  sendResponse(req, res, response);
76
76
  };
77
77
  export const notFoundHandler = (req, res, _next) => {
78
- const message = `Route not found: ${req.method} ${req.path}`;
79
78
  log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
80
79
  if (wantsJson(req)) {
81
- res.status(404).json({ error: { code: 'NOT_FOUND', message, requestId: req.requestId } });
80
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found', requestId: req.requestId } });
82
81
  }
83
82
  else {
84
- res.status(404).send(errorPage404Message(message));
83
+ res.status(404).send(errorPage404Message('The requested page was not found'));
85
84
  }
86
85
  };
@@ -1,3 +1,5 @@
1
+ import crypto from 'node:crypto';
2
+ import { getNodeEnv } from '@constructive-io/graphql-env';
1
3
  import { Logger } from '@pgpmjs/logger';
2
4
  import { createGraphileInstance, graphileCache } from 'graphile-cache';
3
5
  import { ConstructivePreset, makePgService } from 'graphile-settings';
@@ -5,24 +7,71 @@ import { buildConnectionString } from 'pg-cache';
5
7
  import { getPgEnvOptions } from 'pg-env';
6
8
  import './types'; // for Request type
7
9
  import { HandlerCreationError } from '../errors/api-errors';
10
+ const maskErrorLog = new Logger('graphile:maskError');
11
+ const SAFE_ERROR_CODES = new Set([
12
+ // GraphQL standard
13
+ 'GRAPHQL_VALIDATION_FAILED',
14
+ 'GRAPHQL_PARSE_FAILED',
15
+ 'PERSISTED_QUERY_NOT_FOUND',
16
+ 'PERSISTED_QUERY_NOT_SUPPORTED',
17
+ // Auth
18
+ 'UNAUTHENTICATED',
19
+ 'FORBIDDEN',
20
+ 'BAD_USER_INPUT',
21
+ 'INCORRECT_PASSWORD',
22
+ 'PASSWORD_INSECURE',
23
+ 'ACCOUNT_LOCKED_EXCEED_ATTEMPTS',
24
+ 'ACCOUNT_DISABLED',
25
+ 'ACCOUNT_EXISTS',
26
+ 'PASSWORD_LEN',
27
+ 'INVITE_NOT_FOUND',
28
+ 'INVITE_LIMIT',
29
+ 'INVITE_EMAIL_NOT_FOUND',
30
+ 'INVALID_CREDENTIALS',
31
+ // PublicKeySignature
32
+ 'FEATURE_DISABLED',
33
+ 'INVALID_PUBLIC_KEY',
34
+ 'INVALID_MESSAGE',
35
+ 'INVALID_SIGNATURE',
36
+ 'NO_ACCOUNT_EXISTS',
37
+ 'BAD_SIGNIN',
38
+ // Upload
39
+ 'UPLOAD_MIMETYPE',
40
+ // PostgreSQL constraint violations (surfaced by PostGraphile)
41
+ '23505', // unique_violation
42
+ '23503', // foreign_key_violation
43
+ '23502', // not_null_violation
44
+ '23514', // check_violation
45
+ '23P01', // exclusion_violation
46
+ ]);
8
47
  /**
9
- * Custom maskError function that always returns the original error.
48
+ * Production-aware error masking function.
10
49
  *
11
- * By default, grafserv masks errors for security (hiding sensitive database errors
12
- * from clients). We disable this masking to show full error messages.
13
- *
14
- * Upstream reference:
15
- * - grafserv defaultMaskError: node_modules/grafserv/dist/options.js
16
- * - SafeError interface: grafast isSafeError() - errors implementing SafeError
17
- * are shown as-is even with default masking
18
- *
19
- * If you need to restore masking behavior, see the upstream implementation which:
20
- * 1. Returns GraphQLError instances as-is
21
- * 2. Returns SafeError instances with their message exposed
22
- * 3. Masks other errors with a hash/ID and logs the original
50
+ * In development: returns errors as-is for debugging.
51
+ * In production: returns errors with explicit codes from the SAFE_ERROR_CODES
52
+ * allowlist as-is, but masks unexpected/database errors with a reference ID
53
+ * and logs the original.
23
54
  */
24
55
  const maskError = (error) => {
25
- return error;
56
+ if (getNodeEnv() === 'development') {
57
+ return error;
58
+ }
59
+ // Only expose errors with codes on the safe allowlist.
60
+ // Note: grafserv strips originalError and internal extensions before
61
+ // serializing to the client, so returning the full error object is safe here.
62
+ if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code)) {
63
+ return error;
64
+ }
65
+ // Mask unexpected/database errors with a reference ID
66
+ const errorId = crypto.randomBytes(8).toString('hex');
67
+ maskErrorLog.error(`[masked-error:${errorId}]`, error);
68
+ return {
69
+ message: `An unexpected error occurred. Reference: ${errorId}`,
70
+ extensions: {
71
+ code: 'INTERNAL_SERVER_ERROR',
72
+ errorId,
73
+ },
74
+ };
26
75
  };
27
76
  // =============================================================================
28
77
  // Single-Flight Pattern: In-Flight Tracking
@@ -54,7 +103,7 @@ export function clearInFlightMap() {
54
103
  creating.clear();
55
104
  }
56
105
  const log = new Logger('graphile');
57
- const reqLabel = (req) => req.requestId ? `[${req.requestId}]` : '[req]';
106
+ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
58
107
  /**
59
108
  * Build a PostGraphile v5 preset for a tenant
60
109
  */
@@ -70,6 +119,7 @@ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
70
119
  graphqlPath: '/graphql',
71
120
  graphiqlPath: '/graphiql',
72
121
  graphiql: true,
122
+ graphiqlOnGraphQLGET: false,
73
123
  maskError,
74
124
  },
75
125
  grafast: {
@@ -147,13 +197,26 @@ export const graphile = (opts) => {
147
197
  return instance.handler(req, res, next);
148
198
  }
149
199
  catch (error) {
150
- // Re-throw to be caught by outer try-catch
151
- throw error;
200
+ log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
201
+ // Fall through to Phase C to retry creation
152
202
  }
153
203
  }
154
204
  // =========================================================================
155
205
  // Phase C: Create New Handler (first request for this key)
156
206
  // =========================================================================
207
+ // Re-check cache after coalesced request failure (another retry may have succeeded)
208
+ const recheckedCache = graphileCache.get(key);
209
+ if (recheckedCache) {
210
+ log.debug(`${label} PostGraphile cache hit on re-check key=${key}`);
211
+ return recheckedCache.handler(req, res, next);
212
+ }
213
+ // Re-check in-flight map (another retry may have started creation)
214
+ const retryInFlight = creating.get(key);
215
+ if (retryInFlight) {
216
+ log.debug(`${label} Re-coalescing request for PostGraphile[${key}]`);
217
+ const retryInstance = await retryInFlight;
218
+ return retryInstance.handler(req, res, next);
219
+ }
157
220
  log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
158
221
  const pgConfig = getPgEnvOptions({
159
222
  ...opts.pg,
@@ -172,7 +235,10 @@ export const graphile = (opts) => {
172
235
  }
173
236
  catch (error) {
174
237
  log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
175
- throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, { cacheKey: key, cause: error instanceof Error ? error.message : String(error) });
238
+ throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
239
+ cacheKey: key,
240
+ cause: error instanceof Error ? error.message : String(error),
241
+ });
176
242
  }
177
243
  finally {
178
244
  // Always clean up in-flight tracker
@@ -181,7 +247,12 @@ export const graphile = (opts) => {
181
247
  }
182
248
  catch (e) {
183
249
  log.error(`${label} PostGraphile middleware error`, e);
184
- return res.status(500).send(e.message);
250
+ if (!res.headersSent) {
251
+ return res.status(500).json({
252
+ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }
253
+ });
254
+ }
255
+ next(e);
185
256
  }
186
257
  };
187
258
  };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
3
+ *
4
+ * graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
5
+ * and sets `req.body` to a standard GraphQL operation object, but leaves the
6
+ * original Content-Type header unchanged. grafserv rejects any Content-Type it
7
+ * doesn't recognize (415 Unsupported Media Type) because its allowlist only
8
+ * includes `application/json` and `application/graphql`.
9
+ *
10
+ * This middleware rewrites the Content-Type header to `application/json` after
11
+ * graphql-upload has finished parsing, so grafserv accepts the request.
12
+ *
13
+ * This is the standard bridge pattern for grafserv + graphql-upload.
14
+ * See: https://github.com/graphql/graphql-http/discussions/36
15
+ *
16
+ * Must be registered immediately after `graphqlUploadExpress()` in the
17
+ * middleware chain — it relies on graphql-upload having already parsed the
18
+ * multipart body into `req.body`.
19
+ *
20
+ * Security note: multipart/form-data is a CORS "simple request" content type,
21
+ * which means browsers send it without a preflight OPTIONS check. The CSRF risk
22
+ * is mitigated by the CORS middleware that runs before this middleware in the
23
+ * Express pipeline.
24
+ */
25
+ export const multipartBridge = (req, _res, next) => {
26
+ if (req.body != null && req.headers['content-type']?.startsWith('multipart/form-data')) {
27
+ req.headers['content-type'] = 'application/json';
28
+ }
29
+ next();
30
+ };
@@ -0,0 +1,259 @@
1
+ import { Logger } from '@pgpmjs/logger';
2
+ import fs from 'fs';
3
+ import multer from 'multer';
4
+ import os from 'os';
5
+ import { escapeIdentifier } from 'pg';
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_BASE_SQL = `
50
+ SELECT
51
+ rm.authenticate,
52
+ rm.authenticate_strict,
53
+ ps.schema_name as private_schema_name
54
+ FROM metaschema_modules_public.rls_module rm
55
+ LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id`;
56
+ const RLS_MODULE_BY_DATABASE_ID_SQL = `${RLS_MODULE_BASE_SQL}
57
+ JOIN services_public.apis a ON rm.api_id = a.id
58
+ WHERE a.database_id = $1
59
+ ORDER BY a.id
60
+ LIMIT 1
61
+ `;
62
+ const RLS_MODULE_BY_API_ID_SQL = `${RLS_MODULE_BASE_SQL}
63
+ WHERE rm.api_id = $1
64
+ LIMIT 1
65
+ `;
66
+ const RLS_MODULE_BY_DBNAME_SQL = `${RLS_MODULE_BASE_SQL}
67
+ JOIN services_public.apis a ON rm.api_id = a.id
68
+ WHERE a.dbname = $1
69
+ ORDER BY a.id
70
+ LIMIT 1
71
+ `;
72
+ const toRlsModule = (row) => {
73
+ if (!row || !row.private_schema_name)
74
+ return undefined;
75
+ return {
76
+ authenticate: row.authenticate ?? undefined,
77
+ authenticateStrict: row.authenticate_strict ?? undefined,
78
+ privateSchema: { schemaName: row.private_schema_name },
79
+ };
80
+ };
81
+ const getBearerToken = (authorization) => {
82
+ if (!authorization)
83
+ return null;
84
+ const [authType, authToken] = authorization.split(' ');
85
+ if (authType?.toLowerCase() !== 'bearer' || !authToken) {
86
+ return null;
87
+ }
88
+ return authToken;
89
+ };
90
+ const queryRlsModuleByDatabaseId = async (pool, databaseId) => {
91
+ const result = await pool.query(RLS_MODULE_BY_DATABASE_ID_SQL, [databaseId]);
92
+ return toRlsModule(result.rows[0] ?? null);
93
+ };
94
+ const queryRlsModuleByApiId = async (pool, apiId) => {
95
+ const result = await pool.query(RLS_MODULE_BY_API_ID_SQL, [apiId]);
96
+ return toRlsModule(result.rows[0] ?? null);
97
+ };
98
+ const queryRlsModuleByDbname = async (pool, dbname) => {
99
+ const result = await pool.query(RLS_MODULE_BY_DBNAME_SQL, [dbname]);
100
+ return toRlsModule(result.rows[0] ?? null);
101
+ };
102
+ const resolveUploadRlsModule = async (opts, req) => {
103
+ const api = req.api;
104
+ if (!api)
105
+ return undefined;
106
+ // Use API-scoped RLS module when available (e.g., meta API).
107
+ if (api.rlsModule) {
108
+ return api.rlsModule;
109
+ }
110
+ const pool = getPgPool(opts.pg);
111
+ if (api.apiId) {
112
+ const byApiId = await queryRlsModuleByApiId(pool, api.apiId);
113
+ if (byApiId)
114
+ return byApiId;
115
+ }
116
+ if (api.databaseId) {
117
+ const byDatabaseId = await queryRlsModuleByDatabaseId(pool, api.databaseId);
118
+ if (byDatabaseId)
119
+ return byDatabaseId;
120
+ }
121
+ if (api.dbname) {
122
+ return queryRlsModuleByDbname(pool, api.dbname);
123
+ }
124
+ return undefined;
125
+ };
126
+ const authError = (res) => res.status(401).json({ error: 'Authentication required' });
127
+ /**
128
+ * Upload-specific authentication middleware.
129
+ *
130
+ * This middleware enforces strict auth semantics for `POST /upload` while
131
+ * preserving existing GraphQL auth behavior for other routes.
132
+ */
133
+ export const createUploadAuthenticateMiddleware = (opts) => {
134
+ return async (req, res, next) => {
135
+ const api = req.api;
136
+ if (!api) {
137
+ res.status(500).send('Missing API info');
138
+ return;
139
+ }
140
+ if (req.token?.user_id) {
141
+ next();
142
+ return;
143
+ }
144
+ const authToken = getBearerToken(req.headers.authorization);
145
+ if (!authToken) {
146
+ authError(res);
147
+ return;
148
+ }
149
+ let rlsModule;
150
+ try {
151
+ rlsModule = await resolveUploadRlsModule(opts, req);
152
+ }
153
+ catch (error) {
154
+ authLog.error('[upload-auth] Failed to resolve RLS module for upload route', error);
155
+ authError(res);
156
+ return;
157
+ }
158
+ if (!rlsModule) {
159
+ authLog.info(`[upload-auth] No RLS module found for db=${api.dbname} databaseId=${api.databaseId ?? 'none'}`);
160
+ authError(res);
161
+ return;
162
+ }
163
+ const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate;
164
+ const privateSchema = rlsModule.privateSchema?.schemaName;
165
+ if (!authFn || !privateSchema) {
166
+ authLog.warn(`[upload-auth] Missing auth function or private schema for db=${api.dbname}; strictAuth=${opts.server?.strictAuth ?? false}`);
167
+ authError(res);
168
+ return;
169
+ }
170
+ const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
171
+ if (!SAFE_IDENTIFIER.test(privateSchema) || !SAFE_IDENTIFIER.test(authFn)) {
172
+ authLog.error(`[upload-auth] Invalid SQL identifier: schema=${privateSchema} fn=${authFn}`);
173
+ authError(res);
174
+ return;
175
+ }
176
+ const pool = getPgPool({
177
+ ...opts.pg,
178
+ database: api.dbname,
179
+ });
180
+ const context = {};
181
+ if (req.clientIp) {
182
+ context['jwt.claims.ip_address'] = req.clientIp;
183
+ }
184
+ if (req.get('origin')) {
185
+ context['jwt.claims.origin'] = req.get('origin');
186
+ }
187
+ if (req.get('User-Agent')) {
188
+ context['jwt.claims.user_agent'] = req.get('User-Agent');
189
+ }
190
+ try {
191
+ const result = await pgQueryContext({
192
+ client: pool,
193
+ context,
194
+ query: `SELECT * FROM ${escapeIdentifier(privateSchema)}.${escapeIdentifier(authFn)}($1)`,
195
+ variables: [authToken],
196
+ });
197
+ if (!result?.rowCount) {
198
+ authError(res);
199
+ return;
200
+ }
201
+ req.token = result.rows[0];
202
+ if (!req.token?.user_id) {
203
+ authError(res);
204
+ return;
205
+ }
206
+ next();
207
+ }
208
+ catch (error) {
209
+ authLog.warn('[upload-auth] Upload authentication failed', error);
210
+ authError(res);
211
+ }
212
+ };
213
+ };
214
+ /**
215
+ * REST file upload endpoint.
216
+ *
217
+ * Accepts a single file via multipart/form-data, streams it to S3/MinIO,
218
+ * and returns file metadata. The frontend uses this in a two-step flow:
219
+ *
220
+ * 1. POST /upload -> { url, filename, mime, size }
221
+ * 2. GraphQL mutation -> patch row with the returned metadata
222
+ */
223
+ export const uploadRoute = [
224
+ parseFileWithErrors,
225
+ (async (req, res, next) => {
226
+ if (!req.token?.user_id) {
227
+ return res.status(401).json({ error: 'Authentication required' });
228
+ }
229
+ if (!req.file) {
230
+ return res.status(400).json({ error: 'No file provided. Send a "file" field.' });
231
+ }
232
+ if (req.file.mimetype && BLOCKED_MIME_TYPES.has(req.file.mimetype)) {
233
+ fs.unlink(req.file.path, () => { });
234
+ return res.status(415).json({ error: 'File type not allowed' });
235
+ }
236
+ try {
237
+ const readStream = fs.createReadStream(req.file.path);
238
+ const result = await streamToStorage(readStream, req.file.originalname);
239
+ uploadLog.debug(`[upload] Uploaded file for user=${req.token.user_id} filename=${req.file.originalname} mime=${result.mime} size=${req.file.size}`);
240
+ res.json({
241
+ url: result.url,
242
+ filename: result.filename,
243
+ mime: result.mime,
244
+ size: req.file.size,
245
+ });
246
+ }
247
+ catch (error) {
248
+ uploadLog.error('[upload] Upload processing failed', error);
249
+ if (!res.headersSent) {
250
+ res.status(500).json({ error: 'Upload processing failed' });
251
+ }
252
+ }
253
+ finally {
254
+ if (req.file?.path) {
255
+ fs.unlink(req.file.path, () => { });
256
+ }
257
+ }
258
+ }),
259
+ ];
package/esm/server.js CHANGED
@@ -1,10 +1,9 @@
1
- import { getEnvOptions, getNodeEnv } from '@constructive-io/graphql-env';
1
+ import { getEnvOptions } from '@constructive-io/graphql-env';
2
2
  import { Logger } from '@pgpmjs/logger';
3
3
  import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
4
4
  import { middleware as parseDomains } from '@constructive-io/url-domains';
5
5
  import { randomUUID } from 'crypto';
6
6
  import express from 'express';
7
- // @ts-ignore
8
7
  import graphqlUpload from 'graphql-upload';
9
8
  import { graphileCache, closeAllCaches } from 'graphile-cache';
10
9
  import { getPgPool } from 'pg-cache';
@@ -16,9 +15,10 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler';
16
15
  import { favicon } from './middleware/favicon';
17
16
  import { flush, flushService } from './middleware/flush';
18
17
  import { graphile } from './middleware/graphile';
18
+ import { multipartBridge } from './middleware/multipart-bridge';
19
+ import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
19
20
  import { normalizeServerOptions } from './options';
20
21
  const log = new Logger('server');
21
- const isDev = () => getNodeEnv() === 'development';
22
22
  /**
23
23
  * Creates and starts a GraphQL server instance
24
24
  *
@@ -64,9 +64,13 @@ class Server {
64
64
  const app = express();
65
65
  const api = createApiMiddleware(effectiveOpts);
66
66
  const authenticate = createAuthenticateMiddleware(effectiveOpts);
67
+ const uploadAuthenticate = createUploadAuthenticateMiddleware(effectiveOpts);
68
+ const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/;
67
69
  const requestLogger = (req, res, next) => {
68
70
  const headerRequestId = req.header('x-request-id');
69
- const reqId = headerRequestId || randomUUID();
71
+ const reqId = (headerRequestId && SAFE_REQUEST_ID.test(headerRequestId))
72
+ ? headerRequestId
73
+ : randomUUID();
70
74
  const start = process.hrtime.bigint();
71
75
  req.requestId = reqId;
72
76
  const host = req.hostname || req.headers.host || 'unknown';
@@ -96,7 +100,7 @@ class Server {
96
100
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
97
101
  exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
98
102
  anonRole: apiOpts.anonRole,
99
- roleName: apiOpts.roleName
103
+ roleName: apiOpts.roleName,
100
104
  });
101
105
  healthz(app);
102
106
  app.use(favicon);
@@ -113,11 +117,17 @@ class Server {
113
117
  }
114
118
  app.use(poweredBy('constructive'));
115
119
  app.use(cors(fallbackOrigin));
116
- app.use(graphqlUpload.graphqlUploadExpress());
120
+ app.use('/graphql', graphqlUpload.graphqlUploadExpress({
121
+ maxFileSize: 10 * 1024 * 1024, // 10 MB
122
+ maxFiles: 10,
123
+ }));
124
+ // Rewrite Content-Type after graphql-upload so grafserv accepts the request
125
+ app.use('/graphql', multipartBridge);
117
126
  app.use(parseDomains());
118
127
  app.use(requestIp.mw());
119
128
  app.use(requestLogger);
120
129
  app.use(api);
130
+ app.post('/upload', uploadAuthenticate, ...uploadRoute);
121
131
  app.use(authenticate);
122
132
  app.use(graphile(effectiveOpts));
123
133
  app.use(flush);
package/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './server';
2
2
  export * from './options';
3
3
  export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api';
4
4
  export { createAuthenticateMiddleware } from './middleware/auth';
5
+ export { createUploadAuthenticateMiddleware } from './middleware/upload';
5
6
  export { cors } from './middleware/cors';
6
7
  export { graphile } from './middleware/graphile';
7
8
  export { flush, flushService } from './middleware/flush';
package/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.flushService = exports.flush = exports.graphile = exports.cors = exports.createAuthenticateMiddleware = exports.getApiConfig = exports.getSubdomain = exports.createApiMiddleware = void 0;
17
+ exports.flushService = exports.flush = exports.graphile = exports.cors = exports.createUploadAuthenticateMiddleware = exports.createAuthenticateMiddleware = exports.getApiConfig = exports.getSubdomain = exports.createApiMiddleware = void 0;
18
18
  __exportStar(require("./server"), exports);
19
19
  // Export options module - types, defaults, type guards, and utility functions
20
20
  __exportStar(require("./options"), exports);
@@ -25,6 +25,8 @@ Object.defineProperty(exports, "getSubdomain", { enumerable: true, get: function
25
25
  Object.defineProperty(exports, "getApiConfig", { enumerable: true, get: function () { return api_1.getApiConfig; } });
26
26
  var auth_1 = require("./middleware/auth");
27
27
  Object.defineProperty(exports, "createAuthenticateMiddleware", { enumerable: true, get: function () { return auth_1.createAuthenticateMiddleware; } });
28
+ var upload_1 = require("./middleware/upload");
29
+ Object.defineProperty(exports, "createUploadAuthenticateMiddleware", { enumerable: true, get: function () { return upload_1.createUploadAuthenticateMiddleware; } });
28
30
  var cors_1 = require("./middleware/cors");
29
31
  Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
30
32
  var graphile_1 = require("./middleware/graphile");
package/middleware/api.js CHANGED
@@ -132,6 +132,7 @@ const toRlsModule = (row) => {
132
132
  };
133
133
  };
134
134
  const toApiStructure = (row, opts, rlsModuleRow) => ({
135
+ apiId: row.api_id,
135
136
  dbname: row.dbname || opts.pg?.database || '',
136
137
  anonRole: row.anon_role || 'anon',
137
138
  roleName: row.role_name || 'authenticated',
@@ -82,13 +82,12 @@ const errorHandler = (err, req, res, _next) => {
82
82
  };
83
83
  exports.errorHandler = errorHandler;
84
84
  const notFoundHandler = (req, res, _next) => {
85
- const message = `Route not found: ${req.method} ${req.path}`;
86
85
  log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
87
86
  if (wantsJson(req)) {
88
- res.status(404).json({ error: { code: 'NOT_FOUND', message, requestId: req.requestId } });
87
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found', requestId: req.requestId } });
89
88
  }
90
89
  else {
91
- res.status(404).send((0, _404_message_1.default)(message));
90
+ res.status(404).send((0, _404_message_1.default)('The requested page was not found'));
92
91
  }
93
92
  };
94
93
  exports.notFoundHandler = notFoundHandler;
@@ -1,5 +1,5 @@
1
- import { ConstructiveOptions } from '@constructive-io/graphql-types';
2
- import { RequestHandler } from 'express';
1
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
2
+ import type { RequestHandler } from 'express';
3
3
  import './types';
4
4
  /**
5
5
  * Returns the number of currently in-flight handler creation operations.
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.graphile = void 0;
4
7
  exports.getInFlightCount = getInFlightCount;
5
8
  exports.getInFlightKeys = getInFlightKeys;
6
9
  exports.clearInFlightMap = clearInFlightMap;
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
11
+ const graphql_env_1 = require("@constructive-io/graphql-env");
7
12
  const logger_1 = require("@pgpmjs/logger");
8
13
  const graphile_cache_1 = require("graphile-cache");
9
14
  const graphile_settings_1 = require("graphile-settings");
@@ -11,24 +16,71 @@ const pg_cache_1 = require("pg-cache");
11
16
  const pg_env_1 = require("pg-env");
12
17
  require("./types"); // for Request type
13
18
  const api_errors_1 = require("../errors/api-errors");
19
+ const maskErrorLog = new logger_1.Logger('graphile:maskError');
20
+ const SAFE_ERROR_CODES = new Set([
21
+ // GraphQL standard
22
+ 'GRAPHQL_VALIDATION_FAILED',
23
+ 'GRAPHQL_PARSE_FAILED',
24
+ 'PERSISTED_QUERY_NOT_FOUND',
25
+ 'PERSISTED_QUERY_NOT_SUPPORTED',
26
+ // Auth
27
+ 'UNAUTHENTICATED',
28
+ 'FORBIDDEN',
29
+ 'BAD_USER_INPUT',
30
+ 'INCORRECT_PASSWORD',
31
+ 'PASSWORD_INSECURE',
32
+ 'ACCOUNT_LOCKED_EXCEED_ATTEMPTS',
33
+ 'ACCOUNT_DISABLED',
34
+ 'ACCOUNT_EXISTS',
35
+ 'PASSWORD_LEN',
36
+ 'INVITE_NOT_FOUND',
37
+ 'INVITE_LIMIT',
38
+ 'INVITE_EMAIL_NOT_FOUND',
39
+ 'INVALID_CREDENTIALS',
40
+ // PublicKeySignature
41
+ 'FEATURE_DISABLED',
42
+ 'INVALID_PUBLIC_KEY',
43
+ 'INVALID_MESSAGE',
44
+ 'INVALID_SIGNATURE',
45
+ 'NO_ACCOUNT_EXISTS',
46
+ 'BAD_SIGNIN',
47
+ // Upload
48
+ 'UPLOAD_MIMETYPE',
49
+ // PostgreSQL constraint violations (surfaced by PostGraphile)
50
+ '23505', // unique_violation
51
+ '23503', // foreign_key_violation
52
+ '23502', // not_null_violation
53
+ '23514', // check_violation
54
+ '23P01', // exclusion_violation
55
+ ]);
14
56
  /**
15
- * Custom maskError function that always returns the original error.
16
- *
17
- * By default, grafserv masks errors for security (hiding sensitive database errors
18
- * from clients). We disable this masking to show full error messages.
57
+ * Production-aware error masking function.
19
58
  *
20
- * Upstream reference:
21
- * - grafserv defaultMaskError: node_modules/grafserv/dist/options.js
22
- * - SafeError interface: grafast isSafeError() - errors implementing SafeError
23
- * are shown as-is even with default masking
24
- *
25
- * If you need to restore masking behavior, see the upstream implementation which:
26
- * 1. Returns GraphQLError instances as-is
27
- * 2. Returns SafeError instances with their message exposed
28
- * 3. Masks other errors with a hash/ID and logs the original
59
+ * In development: returns errors as-is for debugging.
60
+ * In production: returns errors with explicit codes from the SAFE_ERROR_CODES
61
+ * allowlist as-is, but masks unexpected/database errors with a reference ID
62
+ * and logs the original.
29
63
  */
30
64
  const maskError = (error) => {
31
- return error;
65
+ if ((0, graphql_env_1.getNodeEnv)() === 'development') {
66
+ return error;
67
+ }
68
+ // Only expose errors with codes on the safe allowlist.
69
+ // Note: grafserv strips originalError and internal extensions before
70
+ // serializing to the client, so returning the full error object is safe here.
71
+ if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code)) {
72
+ return error;
73
+ }
74
+ // Mask unexpected/database errors with a reference ID
75
+ const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
76
+ maskErrorLog.error(`[masked-error:${errorId}]`, error);
77
+ return {
78
+ message: `An unexpected error occurred. Reference: ${errorId}`,
79
+ extensions: {
80
+ code: 'INTERNAL_SERVER_ERROR',
81
+ errorId,
82
+ },
83
+ };
32
84
  };
33
85
  // =============================================================================
34
86
  // Single-Flight Pattern: In-Flight Tracking
@@ -60,7 +112,7 @@ function clearInFlightMap() {
60
112
  creating.clear();
61
113
  }
62
114
  const log = new logger_1.Logger('graphile');
63
- const reqLabel = (req) => req.requestId ? `[${req.requestId}]` : '[req]';
115
+ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
64
116
  /**
65
117
  * Build a PostGraphile v5 preset for a tenant
66
118
  */
@@ -76,6 +128,7 @@ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
76
128
  graphqlPath: '/graphql',
77
129
  graphiqlPath: '/graphiql',
78
130
  graphiql: true,
131
+ graphiqlOnGraphQLGET: false,
79
132
  maskError,
80
133
  },
81
134
  grafast: {
@@ -153,13 +206,26 @@ const graphile = (opts) => {
153
206
  return instance.handler(req, res, next);
154
207
  }
155
208
  catch (error) {
156
- // Re-throw to be caught by outer try-catch
157
- throw error;
209
+ log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
210
+ // Fall through to Phase C to retry creation
158
211
  }
159
212
  }
160
213
  // =========================================================================
161
214
  // Phase C: Create New Handler (first request for this key)
162
215
  // =========================================================================
216
+ // Re-check cache after coalesced request failure (another retry may have succeeded)
217
+ const recheckedCache = graphile_cache_1.graphileCache.get(key);
218
+ if (recheckedCache) {
219
+ log.debug(`${label} PostGraphile cache hit on re-check key=${key}`);
220
+ return recheckedCache.handler(req, res, next);
221
+ }
222
+ // Re-check in-flight map (another retry may have started creation)
223
+ const retryInFlight = creating.get(key);
224
+ if (retryInFlight) {
225
+ log.debug(`${label} Re-coalescing request for PostGraphile[${key}]`);
226
+ const retryInstance = await retryInFlight;
227
+ return retryInstance.handler(req, res, next);
228
+ }
163
229
  log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
164
230
  const pgConfig = (0, pg_env_1.getPgEnvOptions)({
165
231
  ...opts.pg,
@@ -178,7 +244,10 @@ const graphile = (opts) => {
178
244
  }
179
245
  catch (error) {
180
246
  log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
181
- throw new api_errors_1.HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, { cacheKey: key, cause: error instanceof Error ? error.message : String(error) });
247
+ throw new api_errors_1.HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
248
+ cacheKey: key,
249
+ cause: error instanceof Error ? error.message : String(error),
250
+ });
182
251
  }
183
252
  finally {
184
253
  // Always clean up in-flight tracker
@@ -187,7 +256,12 @@ const graphile = (opts) => {
187
256
  }
188
257
  catch (e) {
189
258
  log.error(`${label} PostGraphile middleware error`, e);
190
- return res.status(500).send(e.message);
259
+ if (!res.headersSent) {
260
+ return res.status(500).json({
261
+ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }
262
+ });
263
+ }
264
+ next(e);
191
265
  }
192
266
  };
193
267
  };
@@ -0,0 +1,26 @@
1
+ import type { RequestHandler } from 'express';
2
+ /**
3
+ * Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
4
+ *
5
+ * graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
6
+ * and sets `req.body` to a standard GraphQL operation object, but leaves the
7
+ * original Content-Type header unchanged. grafserv rejects any Content-Type it
8
+ * doesn't recognize (415 Unsupported Media Type) because its allowlist only
9
+ * includes `application/json` and `application/graphql`.
10
+ *
11
+ * This middleware rewrites the Content-Type header to `application/json` after
12
+ * graphql-upload has finished parsing, so grafserv accepts the request.
13
+ *
14
+ * This is the standard bridge pattern for grafserv + graphql-upload.
15
+ * See: https://github.com/graphql/graphql-http/discussions/36
16
+ *
17
+ * Must be registered immediately after `graphqlUploadExpress()` in the
18
+ * middleware chain — it relies on graphql-upload having already parsed the
19
+ * multipart body into `req.body`.
20
+ *
21
+ * Security note: multipart/form-data is a CORS "simple request" content type,
22
+ * which means browsers send it without a preflight OPTIONS check. The CSRF risk
23
+ * is mitigated by the CORS middleware that runs before this middleware in the
24
+ * Express pipeline.
25
+ */
26
+ export declare const multipartBridge: RequestHandler;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.multipartBridge = void 0;
4
+ /**
5
+ * Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
6
+ *
7
+ * graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
8
+ * and sets `req.body` to a standard GraphQL operation object, but leaves the
9
+ * original Content-Type header unchanged. grafserv rejects any Content-Type it
10
+ * doesn't recognize (415 Unsupported Media Type) because its allowlist only
11
+ * includes `application/json` and `application/graphql`.
12
+ *
13
+ * This middleware rewrites the Content-Type header to `application/json` after
14
+ * graphql-upload has finished parsing, so grafserv accepts the request.
15
+ *
16
+ * This is the standard bridge pattern for grafserv + graphql-upload.
17
+ * See: https://github.com/graphql/graphql-http/discussions/36
18
+ *
19
+ * Must be registered immediately after `graphqlUploadExpress()` in the
20
+ * middleware chain — it relies on graphql-upload having already parsed the
21
+ * multipart body into `req.body`.
22
+ *
23
+ * Security note: multipart/form-data is a CORS "simple request" content type,
24
+ * which means browsers send it without a preflight OPTIONS check. The CSRF risk
25
+ * is mitigated by the CORS middleware that runs before this middleware in the
26
+ * Express pipeline.
27
+ */
28
+ const multipartBridge = (req, _res, next) => {
29
+ if (req.body != null && req.headers['content-type']?.startsWith('multipart/form-data')) {
30
+ req.headers['content-type'] = 'application/json';
31
+ }
32
+ next();
33
+ };
34
+ exports.multipartBridge = multipartBridge;
@@ -1,7 +1,7 @@
1
1
  import type { ApiStructure } from '../types';
2
2
  export type ConstructiveAPIToken = {
3
- id: string;
4
- user_id: string;
3
+ id?: string;
4
+ user_id?: string;
5
5
  [key: string]: unknown;
6
6
  };
7
7
  declare global {
@@ -0,0 +1,20 @@
1
+ import type { PgpmOptions } from '@pgpmjs/types';
2
+ import type { RequestHandler } from 'express';
3
+ import './types';
4
+ /**
5
+ * Upload-specific authentication middleware.
6
+ *
7
+ * This middleware enforces strict auth semantics for `POST /upload` while
8
+ * preserving existing GraphQL auth behavior for other routes.
9
+ */
10
+ export declare const createUploadAuthenticateMiddleware: (opts: PgpmOptions) => RequestHandler;
11
+ /**
12
+ * REST file upload endpoint.
13
+ *
14
+ * Accepts a single file via multipart/form-data, streams it to S3/MinIO,
15
+ * and returns file metadata. The frontend uses this in a two-step flow:
16
+ *
17
+ * 1. POST /upload -> { url, filename, mime, size }
18
+ * 2. GraphQL mutation -> patch row with the returned metadata
19
+ */
20
+ export declare const uploadRoute: RequestHandler[];
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.uploadRoute = exports.createUploadAuthenticateMiddleware = void 0;
7
+ const logger_1 = require("@pgpmjs/logger");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const multer_1 = __importDefault(require("multer"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const pg_1 = require("pg");
12
+ const pg_cache_1 = require("pg-cache");
13
+ const pg_query_context_1 = __importDefault(require("pg-query-context"));
14
+ const graphile_settings_1 = require("graphile-settings");
15
+ require("./types");
16
+ const uploadLog = new logger_1.Logger('upload');
17
+ const authLog = new logger_1.Logger('upload-auth');
18
+ const envFileSize = process.env.MAX_UPLOAD_FILE_SIZE
19
+ ? parseInt(process.env.MAX_UPLOAD_FILE_SIZE, 10)
20
+ : NaN;
21
+ const MAX_FILE_SIZE = envFileSize > 0 ? envFileSize : 10 * 1024 * 1024;
22
+ const BLOCKED_MIME_TYPES = new Set([
23
+ 'application/x-executable',
24
+ 'application/x-sharedlib',
25
+ 'application/x-mach-binary',
26
+ 'application/x-dosexec',
27
+ 'text/html',
28
+ 'application/xhtml+xml',
29
+ 'application/javascript',
30
+ 'text/javascript'
31
+ ]);
32
+ const parseFile = (0, multer_1.default)({
33
+ storage: multer_1.default.diskStorage({
34
+ destination: os_1.default.tmpdir(),
35
+ filename: (_req, _file, cb) => {
36
+ const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
37
+ cb(null, `upload-${uniqueSuffix}.tmp`);
38
+ },
39
+ }),
40
+ limits: { fileSize: MAX_FILE_SIZE },
41
+ }).single('file');
42
+ const parseFileWithErrors = (req, res, next) => {
43
+ parseFile(req, res, (err) => {
44
+ if (!err)
45
+ return next();
46
+ if (err.code === 'LIMIT_FILE_SIZE') {
47
+ return res.status(413).json({ error: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)} MB` });
48
+ }
49
+ if (err.code === 'LIMIT_UNEXPECTED_FILE') {
50
+ return res.status(400).json({ error: 'Unexpected file field. Send a single file as "file".' });
51
+ }
52
+ return res.status(400).json({ error: 'File upload failed' });
53
+ });
54
+ };
55
+ const RLS_MODULE_BASE_SQL = `
56
+ SELECT
57
+ rm.authenticate,
58
+ rm.authenticate_strict,
59
+ ps.schema_name as private_schema_name
60
+ FROM metaschema_modules_public.rls_module rm
61
+ LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id`;
62
+ const RLS_MODULE_BY_DATABASE_ID_SQL = `${RLS_MODULE_BASE_SQL}
63
+ JOIN services_public.apis a ON rm.api_id = a.id
64
+ WHERE a.database_id = $1
65
+ ORDER BY a.id
66
+ LIMIT 1
67
+ `;
68
+ const RLS_MODULE_BY_API_ID_SQL = `${RLS_MODULE_BASE_SQL}
69
+ WHERE rm.api_id = $1
70
+ LIMIT 1
71
+ `;
72
+ const RLS_MODULE_BY_DBNAME_SQL = `${RLS_MODULE_BASE_SQL}
73
+ JOIN services_public.apis a ON rm.api_id = a.id
74
+ WHERE a.dbname = $1
75
+ ORDER BY a.id
76
+ LIMIT 1
77
+ `;
78
+ const toRlsModule = (row) => {
79
+ if (!row || !row.private_schema_name)
80
+ return undefined;
81
+ return {
82
+ authenticate: row.authenticate ?? undefined,
83
+ authenticateStrict: row.authenticate_strict ?? undefined,
84
+ privateSchema: { schemaName: row.private_schema_name },
85
+ };
86
+ };
87
+ const getBearerToken = (authorization) => {
88
+ if (!authorization)
89
+ return null;
90
+ const [authType, authToken] = authorization.split(' ');
91
+ if (authType?.toLowerCase() !== 'bearer' || !authToken) {
92
+ return null;
93
+ }
94
+ return authToken;
95
+ };
96
+ const queryRlsModuleByDatabaseId = async (pool, databaseId) => {
97
+ const result = await pool.query(RLS_MODULE_BY_DATABASE_ID_SQL, [databaseId]);
98
+ return toRlsModule(result.rows[0] ?? null);
99
+ };
100
+ const queryRlsModuleByApiId = async (pool, apiId) => {
101
+ const result = await pool.query(RLS_MODULE_BY_API_ID_SQL, [apiId]);
102
+ return toRlsModule(result.rows[0] ?? null);
103
+ };
104
+ const queryRlsModuleByDbname = async (pool, dbname) => {
105
+ const result = await pool.query(RLS_MODULE_BY_DBNAME_SQL, [dbname]);
106
+ return toRlsModule(result.rows[0] ?? null);
107
+ };
108
+ const resolveUploadRlsModule = async (opts, req) => {
109
+ const api = req.api;
110
+ if (!api)
111
+ return undefined;
112
+ // Use API-scoped RLS module when available (e.g., meta API).
113
+ if (api.rlsModule) {
114
+ return api.rlsModule;
115
+ }
116
+ const pool = (0, pg_cache_1.getPgPool)(opts.pg);
117
+ if (api.apiId) {
118
+ const byApiId = await queryRlsModuleByApiId(pool, api.apiId);
119
+ if (byApiId)
120
+ return byApiId;
121
+ }
122
+ if (api.databaseId) {
123
+ const byDatabaseId = await queryRlsModuleByDatabaseId(pool, api.databaseId);
124
+ if (byDatabaseId)
125
+ return byDatabaseId;
126
+ }
127
+ if (api.dbname) {
128
+ return queryRlsModuleByDbname(pool, api.dbname);
129
+ }
130
+ return undefined;
131
+ };
132
+ const authError = (res) => res.status(401).json({ error: 'Authentication required' });
133
+ /**
134
+ * Upload-specific authentication middleware.
135
+ *
136
+ * This middleware enforces strict auth semantics for `POST /upload` while
137
+ * preserving existing GraphQL auth behavior for other routes.
138
+ */
139
+ const createUploadAuthenticateMiddleware = (opts) => {
140
+ return async (req, res, next) => {
141
+ const api = req.api;
142
+ if (!api) {
143
+ res.status(500).send('Missing API info');
144
+ return;
145
+ }
146
+ if (req.token?.user_id) {
147
+ next();
148
+ return;
149
+ }
150
+ const authToken = getBearerToken(req.headers.authorization);
151
+ if (!authToken) {
152
+ authError(res);
153
+ return;
154
+ }
155
+ let rlsModule;
156
+ try {
157
+ rlsModule = await resolveUploadRlsModule(opts, req);
158
+ }
159
+ catch (error) {
160
+ authLog.error('[upload-auth] Failed to resolve RLS module for upload route', error);
161
+ authError(res);
162
+ return;
163
+ }
164
+ if (!rlsModule) {
165
+ authLog.info(`[upload-auth] No RLS module found for db=${api.dbname} databaseId=${api.databaseId ?? 'none'}`);
166
+ authError(res);
167
+ return;
168
+ }
169
+ const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate;
170
+ const privateSchema = rlsModule.privateSchema?.schemaName;
171
+ if (!authFn || !privateSchema) {
172
+ authLog.warn(`[upload-auth] Missing auth function or private schema for db=${api.dbname}; strictAuth=${opts.server?.strictAuth ?? false}`);
173
+ authError(res);
174
+ return;
175
+ }
176
+ const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
177
+ if (!SAFE_IDENTIFIER.test(privateSchema) || !SAFE_IDENTIFIER.test(authFn)) {
178
+ authLog.error(`[upload-auth] Invalid SQL identifier: schema=${privateSchema} fn=${authFn}`);
179
+ authError(res);
180
+ return;
181
+ }
182
+ const pool = (0, pg_cache_1.getPgPool)({
183
+ ...opts.pg,
184
+ database: api.dbname,
185
+ });
186
+ const context = {};
187
+ if (req.clientIp) {
188
+ context['jwt.claims.ip_address'] = req.clientIp;
189
+ }
190
+ if (req.get('origin')) {
191
+ context['jwt.claims.origin'] = req.get('origin');
192
+ }
193
+ if (req.get('User-Agent')) {
194
+ context['jwt.claims.user_agent'] = req.get('User-Agent');
195
+ }
196
+ try {
197
+ const result = await (0, pg_query_context_1.default)({
198
+ client: pool,
199
+ context,
200
+ query: `SELECT * FROM ${(0, pg_1.escapeIdentifier)(privateSchema)}.${(0, pg_1.escapeIdentifier)(authFn)}($1)`,
201
+ variables: [authToken],
202
+ });
203
+ if (!result?.rowCount) {
204
+ authError(res);
205
+ return;
206
+ }
207
+ req.token = result.rows[0];
208
+ if (!req.token?.user_id) {
209
+ authError(res);
210
+ return;
211
+ }
212
+ next();
213
+ }
214
+ catch (error) {
215
+ authLog.warn('[upload-auth] Upload authentication failed', error);
216
+ authError(res);
217
+ }
218
+ };
219
+ };
220
+ exports.createUploadAuthenticateMiddleware = createUploadAuthenticateMiddleware;
221
+ /**
222
+ * REST file upload endpoint.
223
+ *
224
+ * Accepts a single file via multipart/form-data, streams it to S3/MinIO,
225
+ * and returns file metadata. The frontend uses this in a two-step flow:
226
+ *
227
+ * 1. POST /upload -> { url, filename, mime, size }
228
+ * 2. GraphQL mutation -> patch row with the returned metadata
229
+ */
230
+ exports.uploadRoute = [
231
+ parseFileWithErrors,
232
+ (async (req, res, next) => {
233
+ if (!req.token?.user_id) {
234
+ return res.status(401).json({ error: 'Authentication required' });
235
+ }
236
+ if (!req.file) {
237
+ return res.status(400).json({ error: 'No file provided. Send a "file" field.' });
238
+ }
239
+ if (req.file.mimetype && BLOCKED_MIME_TYPES.has(req.file.mimetype)) {
240
+ fs_1.default.unlink(req.file.path, () => { });
241
+ return res.status(415).json({ error: 'File type not allowed' });
242
+ }
243
+ try {
244
+ const readStream = fs_1.default.createReadStream(req.file.path);
245
+ const result = await (0, graphile_settings_1.streamToStorage)(readStream, req.file.originalname);
246
+ uploadLog.debug(`[upload] Uploaded file for user=${req.token.user_id} filename=${req.file.originalname} mime=${result.mime} size=${req.file.size}`);
247
+ res.json({
248
+ url: result.url,
249
+ filename: result.filename,
250
+ mime: result.mime,
251
+ size: req.file.size,
252
+ });
253
+ }
254
+ catch (error) {
255
+ uploadLog.error('[upload] Upload processing failed', error);
256
+ if (!res.headersSent) {
257
+ res.status(500).json({ error: 'Upload processing failed' });
258
+ }
259
+ }
260
+ finally {
261
+ if (req.file?.path) {
262
+ fs_1.default.unlink(req.file.path, () => { });
263
+ }
264
+ }
265
+ }),
266
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.1.1",
3
+ "version": "4.3.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -39,34 +39,36 @@
39
39
  "backend"
40
40
  ],
41
41
  "dependencies": {
42
- "@constructive-io/graphql-env": "^3.1.0",
43
- "@constructive-io/graphql-types": "^3.0.0",
44
- "@constructive-io/s3-utils": "^2.6.0",
45
- "@constructive-io/upload-names": "^2.6.0",
46
- "@constructive-io/url-domains": "^2.6.0",
42
+ "@constructive-io/graphql-env": "^3.2.0",
43
+ "@constructive-io/graphql-types": "^3.1.0",
44
+ "@constructive-io/s3-utils": "^2.7.0",
45
+ "@constructive-io/upload-names": "^2.7.0",
46
+ "@constructive-io/url-domains": "^2.7.0",
47
47
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.1",
48
48
  "@graphile/simplify-inflection": "8.0.0-rc.3",
49
- "@pgpmjs/logger": "^2.1.0",
50
- "@pgpmjs/server-utils": "^3.1.0",
51
- "@pgpmjs/types": "^2.16.0",
49
+ "@pgpmjs/logger": "^2.2.0",
50
+ "@pgpmjs/server-utils": "^3.2.0",
51
+ "@pgpmjs/types": "^2.17.0",
52
52
  "cors": "^2.8.5",
53
53
  "deepmerge": "^4.3.1",
54
54
  "express": "^5.2.1",
55
- "gql-ast": "^3.0.0",
55
+ "gql-ast": "^3.1.0",
56
56
  "grafast": "1.0.0-rc.7",
57
57
  "grafserv": "1.0.0-rc.6",
58
58
  "graphile-build": "5.0.0-rc.4",
59
59
  "graphile-build-pg": "5.0.0-rc.5",
60
- "graphile-cache": "^3.0.1",
60
+ "graphile-cache": "^3.1.0",
61
61
  "graphile-config": "1.0.0-rc.5",
62
- "graphile-settings": "^4.1.1",
62
+ "graphile-settings": "^4.3.0",
63
+ "graphile-utils": "^5.0.0-rc.5",
63
64
  "graphql": "^16.9.0",
64
65
  "graphql-upload": "^13.0.0",
65
66
  "lru-cache": "^11.2.4",
67
+ "multer": "^2.0.1",
66
68
  "pg": "^8.17.1",
67
- "pg-cache": "^3.0.0",
68
- "pg-env": "^1.4.0",
69
- "pg-query-context": "^2.5.0",
69
+ "pg-cache": "^3.1.0",
70
+ "pg-env": "^1.5.0",
71
+ "pg-query-context": "^2.6.0",
70
72
  "pg-sql2": "5.0.0-rc.4",
71
73
  "postgraphile": "5.0.0-rc.7",
72
74
  "postgraphile-plugin-connection-filter": "3.0.0-rc.1",
@@ -77,12 +79,13 @@
77
79
  "@types/cors": "^2.8.17",
78
80
  "@types/express": "^5.0.6",
79
81
  "@types/graphql-upload": "^8.0.12",
82
+ "@types/multer": "^1.4.12",
80
83
  "@types/pg": "^8.16.0",
81
84
  "@types/request-ip": "^0.0.41",
82
- "graphile-test": "4.1.1",
85
+ "graphile-test": "4.2.0",
83
86
  "makage": "^0.1.10",
84
87
  "nodemon": "^3.1.10",
85
88
  "ts-node": "^10.9.2"
86
89
  },
87
- "gitHead": "921594a77964d3467261c70fed1d529eddabb549"
90
+ "gitHead": "b758178b808ce0bf451e86c0bd7e92079155db7c"
88
91
  }
package/server.js CHANGED
@@ -10,7 +10,6 @@ const server_utils_1 = require("@pgpmjs/server-utils");
10
10
  const url_domains_1 = require("@constructive-io/url-domains");
11
11
  const crypto_1 = require("crypto");
12
12
  const express_1 = __importDefault(require("express"));
13
- // @ts-ignore
14
13
  const graphql_upload_1 = __importDefault(require("graphql-upload"));
15
14
  const graphile_cache_1 = require("graphile-cache");
16
15
  const pg_cache_1 = require("pg-cache");
@@ -22,9 +21,10 @@ const error_handler_1 = require("./middleware/error-handler");
22
21
  const favicon_1 = require("./middleware/favicon");
23
22
  const flush_1 = require("./middleware/flush");
24
23
  const graphile_1 = require("./middleware/graphile");
24
+ const multipart_bridge_1 = require("./middleware/multipart-bridge");
25
+ const upload_1 = require("./middleware/upload");
25
26
  const options_1 = require("./options");
26
27
  const log = new logger_1.Logger('server');
27
- const isDev = () => (0, graphql_env_1.getNodeEnv)() === 'development';
28
28
  /**
29
29
  * Creates and starts a GraphQL server instance
30
30
  *
@@ -71,9 +71,13 @@ class Server {
71
71
  const app = (0, express_1.default)();
72
72
  const api = (0, api_1.createApiMiddleware)(effectiveOpts);
73
73
  const authenticate = (0, auth_1.createAuthenticateMiddleware)(effectiveOpts);
74
+ const uploadAuthenticate = (0, upload_1.createUploadAuthenticateMiddleware)(effectiveOpts);
75
+ const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/;
74
76
  const requestLogger = (req, res, next) => {
75
77
  const headerRequestId = req.header('x-request-id');
76
- const reqId = headerRequestId || (0, crypto_1.randomUUID)();
78
+ const reqId = (headerRequestId && SAFE_REQUEST_ID.test(headerRequestId))
79
+ ? headerRequestId
80
+ : (0, crypto_1.randomUUID)();
77
81
  const start = process.hrtime.bigint();
78
82
  req.requestId = reqId;
79
83
  const host = req.hostname || req.headers.host || 'unknown';
@@ -103,7 +107,7 @@ class Server {
103
107
  metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
104
108
  exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
105
109
  anonRole: apiOpts.anonRole,
106
- roleName: apiOpts.roleName
110
+ roleName: apiOpts.roleName,
107
111
  });
108
112
  (0, server_utils_1.healthz)(app);
109
113
  app.use(favicon_1.favicon);
@@ -120,11 +124,17 @@ class Server {
120
124
  }
121
125
  app.use((0, server_utils_1.poweredBy)('constructive'));
122
126
  app.use((0, cors_1.cors)(fallbackOrigin));
123
- app.use(graphql_upload_1.default.graphqlUploadExpress());
127
+ app.use('/graphql', graphql_upload_1.default.graphqlUploadExpress({
128
+ maxFileSize: 10 * 1024 * 1024, // 10 MB
129
+ maxFiles: 10,
130
+ }));
131
+ // Rewrite Content-Type after graphql-upload so grafserv accepts the request
132
+ app.use('/graphql', multipart_bridge_1.multipartBridge);
124
133
  app.use((0, url_domains_1.middleware)());
125
134
  app.use(request_ip_1.default.mw());
126
135
  app.use(requestLogger);
127
136
  app.use(api);
137
+ app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
128
138
  app.use(authenticate);
129
139
  app.use((0, graphile_1.graphile)(effectiveOpts));
130
140
  app.use(flush_1.flush);
package/types.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface RlsModule {
31
31
  };
32
32
  }
33
33
  export interface ApiStructure {
34
+ apiId?: string;
34
35
  dbname: string;
35
36
  anonRole: string;
36
37
  roleName: string;