@constructive-io/graphql-server 4.29.2 → 4.30.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.
@@ -120,6 +120,7 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
120
120
  cookie_httponly,
121
121
  cookie_max_age,
122
122
  cookie_path,
123
+ remember_me_duration,
123
124
  enable_captcha,
124
125
  captcha_site_key
125
126
  FROM "${schemaName}"."${tableName}"
@@ -198,6 +199,7 @@ const DATABASE_SETTINGS_SQL = `
198
199
  ds.enable_connection_filter,
199
200
  ds.enable_ltree,
200
201
  ds.enable_llm,
202
+ ds.enable_bulk,
201
203
  COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
202
204
  COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
203
205
  COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
@@ -207,7 +209,8 @@ const DATABASE_SETTINGS_SQL = `
207
209
  COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
208
210
  COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
209
211
  COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
210
- COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime
212
+ COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
213
+ COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
211
214
  FROM services_public.database_settings ds
212
215
  LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
213
216
  WHERE ds.database_id = $1
@@ -298,6 +301,7 @@ const toAuthSettings = (row) => {
298
301
  cookieHttponly: row.cookie_httponly,
299
302
  cookieMaxAge: row.cookie_max_age,
300
303
  cookiePath: row.cookie_path,
304
+ rememberMeDuration: row.remember_me_duration,
301
305
  enableCaptcha: row.enable_captcha,
302
306
  captchaSiteKey: row.captcha_site_key,
303
307
  };
@@ -487,6 +491,7 @@ const toDatabaseSettings = (row) => {
487
491
  enableLtree: row.resolved_enable_ltree,
488
492
  enableLlm: row.resolved_enable_llm,
489
493
  enableRealtime: row.resolved_enable_realtime,
494
+ enableBulk: row.resolved_enable_bulk,
490
495
  };
491
496
  };
492
497
  const queryDatabaseSettings = async (pool, databaseId, apiId) => {
@@ -7,6 +7,8 @@ const log = new Logger('auth');
7
7
  const isDev = () => getNodeEnv() === 'development';
8
8
  /** Default cookie name for session tokens. */
9
9
  const SESSION_COOKIE_NAME = 'constructive_session';
10
+ /** Cookie name for trusted device tracking. */
11
+ const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
10
12
  /**
11
13
  * Extract a named cookie value from the raw Cookie header.
12
14
  * Avoids pulling in cookie-parser as a dependency.
@@ -110,6 +112,12 @@ export const createAuthenticateMiddleware = (opts) => {
110
112
  log.info(`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
111
113
  `privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}`);
112
114
  }
115
+ // Read device token cookie for trusted device tracking
116
+ const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
117
+ if (deviceToken) {
118
+ req.deviceToken = deviceToken;
119
+ log.info('[auth] Device token cookie present');
120
+ }
113
121
  next();
114
122
  };
115
123
  };
@@ -0,0 +1,114 @@
1
+ export const SESSION_COOKIE_NAME = 'constructive_session';
2
+ export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
3
+ const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
4
+ /**
5
+ * Build cookie config from AuthSettings with optional remember_me override.
6
+ */
7
+ export const getSessionCookieConfig = (authSettings, rememberMe = false) => {
8
+ const DEFAULT_MAX_AGE = 86400; // 24 hours
9
+ let maxAge = DEFAULT_MAX_AGE;
10
+ if (rememberMe && authSettings?.rememberMeDuration) {
11
+ const parsed = parseInt(authSettings.rememberMeDuration, 10);
12
+ if (!isNaN(parsed))
13
+ maxAge = parsed;
14
+ }
15
+ else if (authSettings?.cookieMaxAge) {
16
+ const parsed = parseInt(authSettings.cookieMaxAge, 10);
17
+ if (!isNaN(parsed))
18
+ maxAge = parsed;
19
+ }
20
+ return {
21
+ secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
22
+ sameSite: authSettings?.cookieSamesite ?? 'lax',
23
+ domain: authSettings?.cookieDomain ?? undefined,
24
+ httpOnly: authSettings?.cookieHttponly ?? true,
25
+ maxAge,
26
+ path: authSettings?.cookiePath ?? '/',
27
+ };
28
+ };
29
+ /**
30
+ * Build cookie config for device token (long-lived, 90 days).
31
+ */
32
+ export const getDeviceTokenCookieConfig = (authSettings) => {
33
+ return {
34
+ secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
35
+ sameSite: authSettings?.cookieSamesite ?? 'lax',
36
+ domain: authSettings?.cookieDomain ?? undefined,
37
+ httpOnly: true,
38
+ maxAge: DEVICE_TOKEN_MAX_AGE,
39
+ path: authSettings?.cookiePath ?? '/',
40
+ };
41
+ };
42
+ /**
43
+ * Set the session cookie with the access token.
44
+ */
45
+ export const setSessionCookie = (res, accessToken, config) => {
46
+ res.cookie(SESSION_COOKIE_NAME, accessToken, {
47
+ secure: config.secure,
48
+ sameSite: config.sameSite,
49
+ domain: config.domain,
50
+ httpOnly: config.httpOnly,
51
+ maxAge: config.maxAge * 1000, // Express expects milliseconds
52
+ path: config.path,
53
+ });
54
+ };
55
+ /**
56
+ * Clear the session cookie.
57
+ */
58
+ export const clearSessionCookie = (res, config) => {
59
+ res.clearCookie(SESSION_COOKIE_NAME, {
60
+ secure: config.secure,
61
+ sameSite: config.sameSite,
62
+ domain: config.domain,
63
+ httpOnly: config.httpOnly,
64
+ path: config.path,
65
+ });
66
+ };
67
+ /**
68
+ * Set the device token cookie (long-lived for trusted device tracking).
69
+ */
70
+ export const setDeviceTokenCookie = (res, deviceToken, config) => {
71
+ res.cookie(DEVICE_TOKEN_COOKIE_NAME, deviceToken, {
72
+ secure: config.secure,
73
+ sameSite: config.sameSite,
74
+ domain: config.domain,
75
+ httpOnly: config.httpOnly,
76
+ maxAge: config.maxAge * 1000,
77
+ path: config.path,
78
+ });
79
+ };
80
+ /**
81
+ * Clear the device token cookie.
82
+ */
83
+ export const clearDeviceTokenCookie = (res, config) => {
84
+ res.clearCookie(DEVICE_TOKEN_COOKIE_NAME, {
85
+ secure: config.secure,
86
+ sameSite: config.sameSite,
87
+ domain: config.domain,
88
+ httpOnly: config.httpOnly,
89
+ path: config.path,
90
+ });
91
+ };
92
+ /**
93
+ * Parse a cookie value from the raw Cookie header.
94
+ * Avoids pulling in cookie-parser as a dependency.
95
+ */
96
+ export const parseCookieValue = (req, cookieName) => {
97
+ const header = req.headers.cookie;
98
+ if (!header)
99
+ return undefined;
100
+ const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
101
+ return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
102
+ };
103
+ /**
104
+ * Get the device token from the request cookie.
105
+ */
106
+ export const getDeviceTokenFromRequest = (req) => {
107
+ return parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME);
108
+ };
109
+ /**
110
+ * Get the session token from the request cookie.
111
+ */
112
+ export const getSessionTokenFromRequest = (req) => {
113
+ return parseCookieValue(req, SESSION_COOKIE_NAME);
114
+ };
@@ -23,6 +23,10 @@ const sanitizeMessage = (error) => {
23
23
  return 'The requested resource does not exist';
24
24
  return 'An unexpected error occurred';
25
25
  };
26
+ const isCsrfError = (err) => {
27
+ const code = err.code;
28
+ return typeof code === 'string' && code.startsWith('CSRF_');
29
+ };
26
30
  const categorizeError = (err) => {
27
31
  if (isApiError(err)) {
28
32
  return {
@@ -32,6 +36,10 @@ const categorizeError = (err) => {
32
36
  logLevel: err.statusCode >= 500 ? 'error' : 'warn',
33
37
  };
34
38
  }
39
+ if (isCsrfError(err)) {
40
+ const code = err.code;
41
+ return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
42
+ }
35
43
  if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
36
44
  return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
37
45
  }
@@ -9,6 +9,7 @@ import './types'; // for Request type
9
9
  import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
10
10
  import { HandlerCreationError } from '../errors/api-errors';
11
11
  import { observeGraphileBuild } from './observability/graphile-build-stats';
12
+ import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
12
13
  const maskErrorLog = new Logger('graphile:maskError');
13
14
  const SAFE_ERROR_CODES = new Set([
14
15
  // GraphQL standard
@@ -186,6 +187,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
186
187
  const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
187
188
  return {
188
189
  extends: [createConstructivePreset(databaseSettings)],
190
+ plugins: [AuthCookiePlugin],
189
191
  pgServices: [
190
192
  makePgService({
191
193
  pool,
@@ -218,6 +220,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
218
220
  if (req.get('User-Agent')) {
219
221
  context['jwt.claims.user_agent'] = req.get('User-Agent');
220
222
  }
223
+ if (req.deviceToken) {
224
+ context['jwt.claims.device_token'] = req.deviceToken;
225
+ }
221
226
  if (req.token?.user_id) {
222
227
  const pgSettings = {
223
228
  role: roleName,
@@ -0,0 +1,286 @@
1
+ import { Logger } from '@pgpmjs/logger';
2
+ import '../middleware/types';
3
+ import { SESSION_COOKIE_NAME, DEVICE_TOKEN_COOKIE_NAME, getSessionCookieConfig, getDeviceTokenCookieConfig, } from '../middleware/cookie';
4
+ const log = new Logger('auth-cookie');
5
+ /**
6
+ * Serialize a cookie to a Set-Cookie header value.
7
+ */
8
+ const serializeCookie = (name, value, config) => {
9
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
10
+ if (config.maxAge !== undefined) {
11
+ parts.push(`Max-Age=${config.maxAge}`);
12
+ }
13
+ if (config.domain) {
14
+ parts.push(`Domain=${config.domain}`);
15
+ }
16
+ if (config.path) {
17
+ parts.push(`Path=${config.path}`);
18
+ }
19
+ if (config.secure) {
20
+ parts.push('Secure');
21
+ }
22
+ if (config.httpOnly) {
23
+ parts.push('HttpOnly');
24
+ }
25
+ if (config.sameSite) {
26
+ parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
27
+ }
28
+ return parts.join('; ');
29
+ };
30
+ /**
31
+ * Serialize a cookie for clearing (expired).
32
+ */
33
+ const serializeClearCookie = (name, config) => {
34
+ const parts = [`${encodeURIComponent(name)}=`];
35
+ parts.push('Max-Age=0');
36
+ if (config.domain) {
37
+ parts.push(`Domain=${config.domain}`);
38
+ }
39
+ if (config.path) {
40
+ parts.push(`Path=${config.path}`);
41
+ }
42
+ if (config.secure) {
43
+ parts.push('Secure');
44
+ }
45
+ if (config.httpOnly) {
46
+ parts.push('HttpOnly');
47
+ }
48
+ if (config.sameSite) {
49
+ parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
50
+ }
51
+ return parts.join('; ');
52
+ };
53
+ /**
54
+ * Auth mutations that should set session cookie on success.
55
+ */
56
+ const SIGN_IN_MUTATIONS = new Set([
57
+ 'signIn',
58
+ 'signUp',
59
+ 'signInSso',
60
+ 'signUpSso',
61
+ 'signInMagicLink',
62
+ 'signUpMagicLink',
63
+ 'signInEmailOtp',
64
+ 'signInSmsOtp',
65
+ 'signUpSms',
66
+ 'completeMfaChallenge',
67
+ 'signInOneTimeToken',
68
+ 'signInCrossOrigin',
69
+ ]);
70
+ /**
71
+ * Auth mutations that should clear the session cookie.
72
+ */
73
+ const SIGN_OUT_MUTATIONS = new Set([
74
+ 'signOut',
75
+ 'revokeSession',
76
+ 'revokeAllSessions',
77
+ ]);
78
+ /**
79
+ * Extract mutation names from a GraphQL query string.
80
+ */
81
+ const extractMutationNames = (query) => {
82
+ const mutations = [];
83
+ if (!/^\s*mutation\b/i.test(query)) {
84
+ return mutations;
85
+ }
86
+ const bodyStart = query.indexOf('{');
87
+ if (bodyStart === -1)
88
+ return mutations;
89
+ const bodyContent = query.slice(bodyStart + 1);
90
+ const fieldPattern = /(\w+)\s*(?:\(|{)/g;
91
+ let match;
92
+ while ((match = fieldPattern.exec(bodyContent)) !== null) {
93
+ const name = match[1];
94
+ if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
95
+ mutations.push(name);
96
+ }
97
+ }
98
+ return mutations;
99
+ };
100
+ /**
101
+ * Extract access token from mutation response.
102
+ */
103
+ const extractAccessToken = (data, mutationName) => {
104
+ const result = data[mutationName];
105
+ if (!result)
106
+ return undefined;
107
+ // Check for non-empty string tokens
108
+ if (typeof result.accessToken === 'string' && result.accessToken)
109
+ return result.accessToken;
110
+ if (typeof result.access_token === 'string' && result.access_token)
111
+ return result.access_token;
112
+ const nested = result.result;
113
+ if (nested) {
114
+ if (typeof nested.accessToken === 'string' && nested.accessToken)
115
+ return nested.accessToken;
116
+ if (typeof nested.access_token === 'string' && nested.access_token)
117
+ return nested.access_token;
118
+ }
119
+ return undefined;
120
+ };
121
+ /**
122
+ * Extract device ID from mutation response.
123
+ */
124
+ const extractDeviceId = (data, mutationName) => {
125
+ const result = data[mutationName];
126
+ if (!result)
127
+ return undefined;
128
+ if (typeof result.deviceId === 'string')
129
+ return result.deviceId;
130
+ if (typeof result.device_id === 'string')
131
+ return result.device_id;
132
+ const nested = result.result;
133
+ if (nested) {
134
+ if (typeof nested.deviceId === 'string')
135
+ return nested.deviceId;
136
+ if (typeof nested.device_id === 'string')
137
+ return nested.device_id;
138
+ }
139
+ return undefined;
140
+ };
141
+ /**
142
+ * Check if request includes remember_me flag.
143
+ */
144
+ const hasRememberMe = (variables) => {
145
+ if (!variables)
146
+ return false;
147
+ return variables.rememberMe === true || variables.remember_me === true;
148
+ };
149
+ /**
150
+ * Get Express request from grafserv request context.
151
+ */
152
+ const getExpressRequest = (requestContext) => {
153
+ return requestContext?.expressv4?.req;
154
+ };
155
+ /**
156
+ * AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
157
+ *
158
+ * This plugin intercepts GraphQL responses and:
159
+ * - Sets session cookies on successful sign-in mutations
160
+ * - Clears session cookies on sign-out mutations
161
+ * - Handles device token cookies for trusted device tracking
162
+ */
163
+ export const AuthCookiePlugin = {
164
+ name: 'AuthCookiePlugin',
165
+ version: '1.0.0',
166
+ grafserv: {
167
+ middleware: {
168
+ processRequest: {
169
+ callback: async (next, event) => {
170
+ const result = await next();
171
+ // Only process buffer results (JSON responses)
172
+ if (!result || result.type !== 'buffer') {
173
+ return result;
174
+ }
175
+ const bufferResult = result;
176
+ const req = getExpressRequest(event.requestDigest.requestContext);
177
+ // Skip if no Express request or not a POST
178
+ if (!req || event.requestDigest.method !== 'POST') {
179
+ return result;
180
+ }
181
+ // Get request body for mutation detection
182
+ // grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer }
183
+ let body;
184
+ if (typeof event.requestDigest.getBody === 'function') {
185
+ try {
186
+ const rawBody = await event.requestDigest.getBody();
187
+ if (rawBody?.type === 'buffer' && rawBody.buffer) {
188
+ const jsonStr = rawBody.buffer.toString('utf8');
189
+ body = JSON.parse(jsonStr);
190
+ }
191
+ }
192
+ catch (e) {
193
+ log.debug('[auth-cookie] Failed to parse body from requestDigest');
194
+ }
195
+ }
196
+ body = body || req.body;
197
+ if (!body?.query) {
198
+ return result;
199
+ }
200
+ // Extract mutation names
201
+ const mutationNames = extractMutationNames(body.query);
202
+ if (mutationNames.length === 0) {
203
+ return result;
204
+ }
205
+ // Check for auth mutations
206
+ const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m));
207
+ const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m));
208
+ if (!signInMutation && !signOutMutation) {
209
+ return result;
210
+ }
211
+ log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`);
212
+ try {
213
+ // Parse response body
214
+ const payload = bufferResult.buffer.toString('utf8');
215
+ const graphqlResponse = JSON.parse(payload);
216
+ // Skip if there are GraphQL errors
217
+ if (graphqlResponse.errors?.length || !graphqlResponse.data) {
218
+ return result;
219
+ }
220
+ const data = graphqlResponse.data;
221
+ const authSettings = req.api?.authSettings;
222
+ const cookiesToSet = [];
223
+ // Handle sign-out mutations
224
+ if (signOutMutation && data[signOutMutation]) {
225
+ log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
226
+ const config = getSessionCookieConfig(authSettings);
227
+ cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config));
228
+ // Also clear device token on sign-out
229
+ const deviceConfig = getDeviceTokenCookieConfig(authSettings);
230
+ cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
231
+ }
232
+ // Handle sign-in mutations
233
+ if (signInMutation) {
234
+ const accessToken = extractAccessToken(data, signInMutation);
235
+ if (accessToken) {
236
+ const rememberMe = hasRememberMe(body.variables);
237
+ const config = getSessionCookieConfig(authSettings, rememberMe);
238
+ log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`);
239
+ cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config));
240
+ const deviceId = extractDeviceId(data, signInMutation);
241
+ if (deviceId) {
242
+ log.info('[auth-cookie] Device ID returned, setting device token cookie');
243
+ const deviceConfig = getDeviceTokenCookieConfig(authSettings);
244
+ cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig));
245
+ }
246
+ }
247
+ }
248
+ // Set cookies directly on Express response and return modified headers
249
+ if (cookiesToSet.length > 0) {
250
+ const res = event.requestDigest.requestContext?.expressv4?.res;
251
+ if (res?.setHeader) {
252
+ // Get existing Set-Cookie headers from Express response
253
+ const existingCookies = res.getHeader('Set-Cookie');
254
+ const allCookies = [];
255
+ if (existingCookies) {
256
+ if (Array.isArray(existingCookies)) {
257
+ allCookies.push(...existingCookies);
258
+ }
259
+ else {
260
+ allCookies.push(existingCookies);
261
+ }
262
+ }
263
+ allCookies.push(...cookiesToSet);
264
+ // Set as array to get multiple Set-Cookie headers
265
+ res.setHeader('Set-Cookie', allCookies);
266
+ }
267
+ // Also update the BufferResult headers for grafserv to pass through
268
+ const existingBufferCookie = bufferResult.headers['set-cookie'];
269
+ const updatedHeaders = { ...bufferResult.headers };
270
+ // Remove set-cookie from grafserv headers since we set it on Express
271
+ delete updatedHeaders['set-cookie'];
272
+ return {
273
+ ...bufferResult,
274
+ headers: updatedHeaders,
275
+ };
276
+ }
277
+ }
278
+ catch (err) {
279
+ log.error('[auth-cookie] Error processing auth response:', err);
280
+ }
281
+ return result;
282
+ },
283
+ },
284
+ },
285
+ },
286
+ };
package/esm/server.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { createCsrfMiddleware } from '@constructive-io/csrf';
1
2
  import { getEnvOptions } from '@constructive-io/graphql-env';
2
3
  import { Logger } from '@pgpmjs/logger';
3
4
  import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
4
5
  import { middleware as parseDomains } from '@constructive-io/url-domains';
6
+ import cookieParser from 'cookie-parser';
5
7
  import express from 'express';
6
8
  import graphqlUpload from 'graphql-upload';
7
9
  import { graphileCache, closeAllCaches } from 'graphile-cache';
@@ -21,7 +23,9 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
21
23
  import { debugMemory } from './middleware/observability/debug-memory';
22
24
  import { localObservabilityOnly } from './middleware/observability/guard';
23
25
  import { createRequestLogger } from './middleware/observability/request-logger';
26
+ // Auth cookie handling is done via AuthCookiePlugin in grafserv
24
27
  import { createCaptchaMiddleware } from './middleware/captcha';
28
+ import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
25
29
  import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
26
30
  import { startDebugSampler } from './diagnostics/debug-sampler';
27
31
  const log = new Logger('server');
@@ -120,6 +124,7 @@ class Server {
120
124
  }
121
125
  }
122
126
  app.use(poweredBy('constructive'));
127
+ app.use(cookieParser());
123
128
  app.use(cors(fallbackOrigin));
124
129
  app.use('/graphql', graphqlUpload.graphqlUploadExpress({
125
130
  maxFileSize: 10 * 1024 * 1024, // 10 MB
@@ -134,6 +139,34 @@ class Server {
134
139
  app.post('/upload', uploadAuthenticate, ...uploadRoute);
135
140
  app.use(authenticate);
136
141
  app.use(createCaptchaMiddleware());
142
+ // CSRF protection for cookie-authenticated requests
143
+ // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
144
+ const csrf = createCsrfMiddleware({
145
+ cookieOptions: {
146
+ httpOnly: false, // SPA clients need to read this via document.cookie
147
+ secure: process.env.NODE_ENV === 'production',
148
+ sameSite: 'lax',
149
+ },
150
+ });
151
+ const csrfProtect = (req, res, next) => {
152
+ // Skip CSRF for Bearer token auth
153
+ const auth = req.headers.authorization;
154
+ if (auth?.toLowerCase().startsWith('bearer ')) {
155
+ return next();
156
+ }
157
+ // Skip if no session cookie (anonymous requests)
158
+ const sessionCookie = parseCookieValue(req, SESSION_COOKIE_NAME);
159
+ if (!sessionCookie) {
160
+ return next();
161
+ }
162
+ // Apply CSRF protection for cookie-authenticated requests
163
+ csrf.protect(req, res, next);
164
+ };
165
+ const csrfSetToken = (req, res, next) => {
166
+ csrf.setToken(req, res, next);
167
+ };
168
+ app.use(csrfSetToken); // Set CSRF token cookie on all requests
169
+ app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
137
170
  app.use(graphile(effectiveOpts));
138
171
  app.use(flush);
139
172
  // Error handling - MUST be LAST
package/middleware/api.js CHANGED
@@ -126,6 +126,7 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
126
126
  cookie_httponly,
127
127
  cookie_max_age,
128
128
  cookie_path,
129
+ remember_me_duration,
129
130
  enable_captcha,
130
131
  captcha_site_key
131
132
  FROM "${schemaName}"."${tableName}"
@@ -204,6 +205,7 @@ const DATABASE_SETTINGS_SQL = `
204
205
  ds.enable_connection_filter,
205
206
  ds.enable_ltree,
206
207
  ds.enable_llm,
208
+ ds.enable_bulk,
207
209
  COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
208
210
  COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
209
211
  COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
@@ -213,7 +215,8 @@ const DATABASE_SETTINGS_SQL = `
213
215
  COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
214
216
  COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
215
217
  COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
216
- COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime
218
+ COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
219
+ COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
217
220
  FROM services_public.database_settings ds
218
221
  LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
219
222
  WHERE ds.database_id = $1
@@ -306,6 +309,7 @@ const toAuthSettings = (row) => {
306
309
  cookieHttponly: row.cookie_httponly,
307
310
  cookieMaxAge: row.cookie_max_age,
308
311
  cookiePath: row.cookie_path,
312
+ rememberMeDuration: row.remember_me_duration,
309
313
  enableCaptcha: row.enable_captcha,
310
314
  captchaSiteKey: row.captcha_site_key,
311
315
  };
@@ -495,6 +499,7 @@ const toDatabaseSettings = (row) => {
495
499
  enableLtree: row.resolved_enable_ltree,
496
500
  enableLlm: row.resolved_enable_llm,
497
501
  enableRealtime: row.resolved_enable_realtime,
502
+ enableBulk: row.resolved_enable_bulk,
498
503
  };
499
504
  };
500
505
  const queryDatabaseSettings = async (pool, databaseId, apiId) => {
@@ -13,6 +13,8 @@ const log = new logger_1.Logger('auth');
13
13
  const isDev = () => (0, env_1.getNodeEnv)() === 'development';
14
14
  /** Default cookie name for session tokens. */
15
15
  const SESSION_COOKIE_NAME = 'constructive_session';
16
+ /** Cookie name for trusted device tracking. */
17
+ const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
16
18
  /**
17
19
  * Extract a named cookie value from the raw Cookie header.
18
20
  * Avoids pulling in cookie-parser as a dependency.
@@ -116,6 +118,12 @@ const createAuthenticateMiddleware = (opts) => {
116
118
  log.info(`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
117
119
  `privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}`);
118
120
  }
121
+ // Read device token cookie for trusted device tracking
122
+ const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
123
+ if (deviceToken) {
124
+ req.deviceToken = deviceToken;
125
+ log.info('[auth] Device token cookie present');
126
+ }
119
127
  next();
120
128
  };
121
129
  };
@@ -0,0 +1,49 @@
1
+ import type { Request, Response } from 'express';
2
+ import type { AuthSettings } from '../types';
3
+ export declare const SESSION_COOKIE_NAME = "constructive_session";
4
+ export declare const DEVICE_TOKEN_COOKIE_NAME = "constructive_device_token";
5
+ export interface CookieConfig {
6
+ secure: boolean;
7
+ sameSite: 'strict' | 'lax' | 'none';
8
+ domain?: string;
9
+ httpOnly: boolean;
10
+ maxAge: number;
11
+ path: string;
12
+ }
13
+ /**
14
+ * Build cookie config from AuthSettings with optional remember_me override.
15
+ */
16
+ export declare const getSessionCookieConfig: (authSettings?: AuthSettings, rememberMe?: boolean) => CookieConfig;
17
+ /**
18
+ * Build cookie config for device token (long-lived, 90 days).
19
+ */
20
+ export declare const getDeviceTokenCookieConfig: (authSettings?: AuthSettings) => CookieConfig;
21
+ /**
22
+ * Set the session cookie with the access token.
23
+ */
24
+ export declare const setSessionCookie: (res: Response, accessToken: string, config: CookieConfig) => void;
25
+ /**
26
+ * Clear the session cookie.
27
+ */
28
+ export declare const clearSessionCookie: (res: Response, config: CookieConfig) => void;
29
+ /**
30
+ * Set the device token cookie (long-lived for trusted device tracking).
31
+ */
32
+ export declare const setDeviceTokenCookie: (res: Response, deviceToken: string, config: CookieConfig) => void;
33
+ /**
34
+ * Clear the device token cookie.
35
+ */
36
+ export declare const clearDeviceTokenCookie: (res: Response, config: CookieConfig) => void;
37
+ /**
38
+ * Parse a cookie value from the raw Cookie header.
39
+ * Avoids pulling in cookie-parser as a dependency.
40
+ */
41
+ export declare const parseCookieValue: (req: Request, cookieName: string) => string | undefined;
42
+ /**
43
+ * Get the device token from the request cookie.
44
+ */
45
+ export declare const getDeviceTokenFromRequest: (req: Request) => string | undefined;
46
+ /**
47
+ * Get the session token from the request cookie.
48
+ */
49
+ export declare const getSessionTokenFromRequest: (req: Request) => string | undefined;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSessionTokenFromRequest = exports.getDeviceTokenFromRequest = exports.parseCookieValue = exports.clearDeviceTokenCookie = exports.setDeviceTokenCookie = exports.clearSessionCookie = exports.setSessionCookie = exports.getDeviceTokenCookieConfig = exports.getSessionCookieConfig = exports.DEVICE_TOKEN_COOKIE_NAME = exports.SESSION_COOKIE_NAME = void 0;
4
+ exports.SESSION_COOKIE_NAME = 'constructive_session';
5
+ exports.DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
6
+ const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
7
+ /**
8
+ * Build cookie config from AuthSettings with optional remember_me override.
9
+ */
10
+ const getSessionCookieConfig = (authSettings, rememberMe = false) => {
11
+ const DEFAULT_MAX_AGE = 86400; // 24 hours
12
+ let maxAge = DEFAULT_MAX_AGE;
13
+ if (rememberMe && authSettings?.rememberMeDuration) {
14
+ const parsed = parseInt(authSettings.rememberMeDuration, 10);
15
+ if (!isNaN(parsed))
16
+ maxAge = parsed;
17
+ }
18
+ else if (authSettings?.cookieMaxAge) {
19
+ const parsed = parseInt(authSettings.cookieMaxAge, 10);
20
+ if (!isNaN(parsed))
21
+ maxAge = parsed;
22
+ }
23
+ return {
24
+ secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
25
+ sameSite: authSettings?.cookieSamesite ?? 'lax',
26
+ domain: authSettings?.cookieDomain ?? undefined,
27
+ httpOnly: authSettings?.cookieHttponly ?? true,
28
+ maxAge,
29
+ path: authSettings?.cookiePath ?? '/',
30
+ };
31
+ };
32
+ exports.getSessionCookieConfig = getSessionCookieConfig;
33
+ /**
34
+ * Build cookie config for device token (long-lived, 90 days).
35
+ */
36
+ const getDeviceTokenCookieConfig = (authSettings) => {
37
+ return {
38
+ secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
39
+ sameSite: authSettings?.cookieSamesite ?? 'lax',
40
+ domain: authSettings?.cookieDomain ?? undefined,
41
+ httpOnly: true,
42
+ maxAge: DEVICE_TOKEN_MAX_AGE,
43
+ path: authSettings?.cookiePath ?? '/',
44
+ };
45
+ };
46
+ exports.getDeviceTokenCookieConfig = getDeviceTokenCookieConfig;
47
+ /**
48
+ * Set the session cookie with the access token.
49
+ */
50
+ const setSessionCookie = (res, accessToken, config) => {
51
+ res.cookie(exports.SESSION_COOKIE_NAME, accessToken, {
52
+ secure: config.secure,
53
+ sameSite: config.sameSite,
54
+ domain: config.domain,
55
+ httpOnly: config.httpOnly,
56
+ maxAge: config.maxAge * 1000, // Express expects milliseconds
57
+ path: config.path,
58
+ });
59
+ };
60
+ exports.setSessionCookie = setSessionCookie;
61
+ /**
62
+ * Clear the session cookie.
63
+ */
64
+ const clearSessionCookie = (res, config) => {
65
+ res.clearCookie(exports.SESSION_COOKIE_NAME, {
66
+ secure: config.secure,
67
+ sameSite: config.sameSite,
68
+ domain: config.domain,
69
+ httpOnly: config.httpOnly,
70
+ path: config.path,
71
+ });
72
+ };
73
+ exports.clearSessionCookie = clearSessionCookie;
74
+ /**
75
+ * Set the device token cookie (long-lived for trusted device tracking).
76
+ */
77
+ const setDeviceTokenCookie = (res, deviceToken, config) => {
78
+ res.cookie(exports.DEVICE_TOKEN_COOKIE_NAME, deviceToken, {
79
+ secure: config.secure,
80
+ sameSite: config.sameSite,
81
+ domain: config.domain,
82
+ httpOnly: config.httpOnly,
83
+ maxAge: config.maxAge * 1000,
84
+ path: config.path,
85
+ });
86
+ };
87
+ exports.setDeviceTokenCookie = setDeviceTokenCookie;
88
+ /**
89
+ * Clear the device token cookie.
90
+ */
91
+ const clearDeviceTokenCookie = (res, config) => {
92
+ res.clearCookie(exports.DEVICE_TOKEN_COOKIE_NAME, {
93
+ secure: config.secure,
94
+ sameSite: config.sameSite,
95
+ domain: config.domain,
96
+ httpOnly: config.httpOnly,
97
+ path: config.path,
98
+ });
99
+ };
100
+ exports.clearDeviceTokenCookie = clearDeviceTokenCookie;
101
+ /**
102
+ * Parse a cookie value from the raw Cookie header.
103
+ * Avoids pulling in cookie-parser as a dependency.
104
+ */
105
+ const parseCookieValue = (req, cookieName) => {
106
+ const header = req.headers.cookie;
107
+ if (!header)
108
+ return undefined;
109
+ const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
110
+ return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
111
+ };
112
+ exports.parseCookieValue = parseCookieValue;
113
+ /**
114
+ * Get the device token from the request cookie.
115
+ */
116
+ const getDeviceTokenFromRequest = (req) => {
117
+ return (0, exports.parseCookieValue)(req, exports.DEVICE_TOKEN_COOKIE_NAME);
118
+ };
119
+ exports.getDeviceTokenFromRequest = getDeviceTokenFromRequest;
120
+ /**
121
+ * Get the session token from the request cookie.
122
+ */
123
+ const getSessionTokenFromRequest = (req) => {
124
+ return (0, exports.parseCookieValue)(req, exports.SESSION_COOKIE_NAME);
125
+ };
126
+ exports.getSessionTokenFromRequest = getSessionTokenFromRequest;
@@ -29,6 +29,10 @@ const sanitizeMessage = (error) => {
29
29
  return 'The requested resource does not exist';
30
30
  return 'An unexpected error occurred';
31
31
  };
32
+ const isCsrfError = (err) => {
33
+ const code = err.code;
34
+ return typeof code === 'string' && code.startsWith('CSRF_');
35
+ };
32
36
  const categorizeError = (err) => {
33
37
  if ((0, api_errors_1.isApiError)(err)) {
34
38
  return {
@@ -38,6 +42,10 @@ const categorizeError = (err) => {
38
42
  logLevel: err.statusCode >= 500 ? 'error' : 'warn',
39
43
  };
40
44
  }
45
+ if (isCsrfError(err)) {
46
+ const code = err.code;
47
+ return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
48
+ }
41
49
  if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
42
50
  return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
43
51
  }
@@ -18,6 +18,7 @@ require("./types"); // for Request type
18
18
  const observability_1 = require("../diagnostics/observability");
19
19
  const api_errors_1 = require("../errors/api-errors");
20
20
  const graphile_build_stats_1 = require("./observability/graphile-build-stats");
21
+ const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
21
22
  const maskErrorLog = new logger_1.Logger('graphile:maskError');
22
23
  const SAFE_ERROR_CODES = new Set([
23
24
  // GraphQL standard
@@ -195,6 +196,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
195
196
  const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
196
197
  return {
197
198
  extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
199
+ plugins: [auth_cookie_plugin_1.AuthCookiePlugin],
198
200
  pgServices: [
199
201
  (0, graphile_settings_1.makePgService)({
200
202
  pool,
@@ -227,6 +229,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
227
229
  if (req.get('User-Agent')) {
228
230
  context['jwt.claims.user_agent'] = req.get('User-Agent');
229
231
  }
232
+ if (req.deviceToken) {
233
+ context['jwt.claims.device_token'] = req.deviceToken;
234
+ }
230
235
  if (req.token?.user_id) {
231
236
  const pgSettings = {
232
237
  role: roleName,
@@ -16,6 +16,8 @@ declare global {
16
16
  databaseId?: string;
17
17
  requestId?: string;
18
18
  token?: ConstructiveAPIToken;
19
+ /** Device token from constructive_device_token cookie for trusted device tracking */
20
+ deviceToken?: string;
19
21
  }
20
22
  }
21
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.29.2",
3
+ "version": "4.30.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -41,6 +41,7 @@
41
41
  "backend"
42
42
  ],
43
43
  "dependencies": {
44
+ "@constructive-io/csrf": "^0.13.1",
44
45
  "@constructive-io/graphql-env": "^3.10.1",
45
46
  "@constructive-io/graphql-types": "^3.9.1",
46
47
  "@constructive-io/s3-utils": "^2.16.1",
@@ -63,7 +64,7 @@
63
64
  "graphile-build-pg": "5.0.0",
64
65
  "graphile-cache": "^3.10.1",
65
66
  "graphile-config": "1.0.0",
66
- "graphile-settings": "^4.33.2",
67
+ "graphile-settings": "^5.0.0",
67
68
  "graphile-utils": "5.0.0",
68
69
  "graphql": "16.13.0",
69
70
  "graphql-upload": "^13.0.0",
@@ -79,16 +80,18 @@
79
80
  },
80
81
  "devDependencies": {
81
82
  "@aws-sdk/client-s3": "^3.1009.0",
83
+ "@types/cookie-parser": "^1.4.10",
82
84
  "@types/cors": "^2.8.17",
83
85
  "@types/express": "^5.0.6",
84
86
  "@types/graphql-upload": "^8.0.12",
85
87
  "@types/multer": "^2.1.0",
86
88
  "@types/pg": "^8.18.0",
87
89
  "@types/request-ip": "^0.0.41",
90
+ "cookie-parser": "^1.4.7",
88
91
  "graphile-test": "4.14.2",
89
92
  "makage": "^0.3.0",
90
93
  "nodemon": "^3.1.14",
91
94
  "ts-node": "^10.9.2"
92
95
  },
93
- "gitHead": "ea590e1b9e1ee38c267f8dbbb37aa3f83a5d3fb7"
96
+ "gitHead": "28b0b236e65b2a2228acad4fd840543c04b24825"
94
97
  }
@@ -0,0 +1,11 @@
1
+ import type { GraphileConfig } from 'graphile-config';
2
+ import '../middleware/types';
3
+ /**
4
+ * AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
5
+ *
6
+ * This plugin intercepts GraphQL responses and:
7
+ * - Sets session cookies on successful sign-in mutations
8
+ * - Clears session cookies on sign-out mutations
9
+ * - Handles device token cookies for trusted device tracking
10
+ */
11
+ export declare const AuthCookiePlugin: GraphileConfig.Plugin;
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthCookiePlugin = void 0;
4
+ const logger_1 = require("@pgpmjs/logger");
5
+ require("../middleware/types");
6
+ const cookie_1 = require("../middleware/cookie");
7
+ const log = new logger_1.Logger('auth-cookie');
8
+ /**
9
+ * Serialize a cookie to a Set-Cookie header value.
10
+ */
11
+ const serializeCookie = (name, value, config) => {
12
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
13
+ if (config.maxAge !== undefined) {
14
+ parts.push(`Max-Age=${config.maxAge}`);
15
+ }
16
+ if (config.domain) {
17
+ parts.push(`Domain=${config.domain}`);
18
+ }
19
+ if (config.path) {
20
+ parts.push(`Path=${config.path}`);
21
+ }
22
+ if (config.secure) {
23
+ parts.push('Secure');
24
+ }
25
+ if (config.httpOnly) {
26
+ parts.push('HttpOnly');
27
+ }
28
+ if (config.sameSite) {
29
+ parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
30
+ }
31
+ return parts.join('; ');
32
+ };
33
+ /**
34
+ * Serialize a cookie for clearing (expired).
35
+ */
36
+ const serializeClearCookie = (name, config) => {
37
+ const parts = [`${encodeURIComponent(name)}=`];
38
+ parts.push('Max-Age=0');
39
+ if (config.domain) {
40
+ parts.push(`Domain=${config.domain}`);
41
+ }
42
+ if (config.path) {
43
+ parts.push(`Path=${config.path}`);
44
+ }
45
+ if (config.secure) {
46
+ parts.push('Secure');
47
+ }
48
+ if (config.httpOnly) {
49
+ parts.push('HttpOnly');
50
+ }
51
+ if (config.sameSite) {
52
+ parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
53
+ }
54
+ return parts.join('; ');
55
+ };
56
+ /**
57
+ * Auth mutations that should set session cookie on success.
58
+ */
59
+ const SIGN_IN_MUTATIONS = new Set([
60
+ 'signIn',
61
+ 'signUp',
62
+ 'signInSso',
63
+ 'signUpSso',
64
+ 'signInMagicLink',
65
+ 'signUpMagicLink',
66
+ 'signInEmailOtp',
67
+ 'signInSmsOtp',
68
+ 'signUpSms',
69
+ 'completeMfaChallenge',
70
+ 'signInOneTimeToken',
71
+ 'signInCrossOrigin',
72
+ ]);
73
+ /**
74
+ * Auth mutations that should clear the session cookie.
75
+ */
76
+ const SIGN_OUT_MUTATIONS = new Set([
77
+ 'signOut',
78
+ 'revokeSession',
79
+ 'revokeAllSessions',
80
+ ]);
81
+ /**
82
+ * Extract mutation names from a GraphQL query string.
83
+ */
84
+ const extractMutationNames = (query) => {
85
+ const mutations = [];
86
+ if (!/^\s*mutation\b/i.test(query)) {
87
+ return mutations;
88
+ }
89
+ const bodyStart = query.indexOf('{');
90
+ if (bodyStart === -1)
91
+ return mutations;
92
+ const bodyContent = query.slice(bodyStart + 1);
93
+ const fieldPattern = /(\w+)\s*(?:\(|{)/g;
94
+ let match;
95
+ while ((match = fieldPattern.exec(bodyContent)) !== null) {
96
+ const name = match[1];
97
+ if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
98
+ mutations.push(name);
99
+ }
100
+ }
101
+ return mutations;
102
+ };
103
+ /**
104
+ * Extract access token from mutation response.
105
+ */
106
+ const extractAccessToken = (data, mutationName) => {
107
+ const result = data[mutationName];
108
+ if (!result)
109
+ return undefined;
110
+ // Check for non-empty string tokens
111
+ if (typeof result.accessToken === 'string' && result.accessToken)
112
+ return result.accessToken;
113
+ if (typeof result.access_token === 'string' && result.access_token)
114
+ return result.access_token;
115
+ const nested = result.result;
116
+ if (nested) {
117
+ if (typeof nested.accessToken === 'string' && nested.accessToken)
118
+ return nested.accessToken;
119
+ if (typeof nested.access_token === 'string' && nested.access_token)
120
+ return nested.access_token;
121
+ }
122
+ return undefined;
123
+ };
124
+ /**
125
+ * Extract device ID from mutation response.
126
+ */
127
+ const extractDeviceId = (data, mutationName) => {
128
+ const result = data[mutationName];
129
+ if (!result)
130
+ return undefined;
131
+ if (typeof result.deviceId === 'string')
132
+ return result.deviceId;
133
+ if (typeof result.device_id === 'string')
134
+ return result.device_id;
135
+ const nested = result.result;
136
+ if (nested) {
137
+ if (typeof nested.deviceId === 'string')
138
+ return nested.deviceId;
139
+ if (typeof nested.device_id === 'string')
140
+ return nested.device_id;
141
+ }
142
+ return undefined;
143
+ };
144
+ /**
145
+ * Check if request includes remember_me flag.
146
+ */
147
+ const hasRememberMe = (variables) => {
148
+ if (!variables)
149
+ return false;
150
+ return variables.rememberMe === true || variables.remember_me === true;
151
+ };
152
+ /**
153
+ * Get Express request from grafserv request context.
154
+ */
155
+ const getExpressRequest = (requestContext) => {
156
+ return requestContext?.expressv4?.req;
157
+ };
158
+ /**
159
+ * AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
160
+ *
161
+ * This plugin intercepts GraphQL responses and:
162
+ * - Sets session cookies on successful sign-in mutations
163
+ * - Clears session cookies on sign-out mutations
164
+ * - Handles device token cookies for trusted device tracking
165
+ */
166
+ exports.AuthCookiePlugin = {
167
+ name: 'AuthCookiePlugin',
168
+ version: '1.0.0',
169
+ grafserv: {
170
+ middleware: {
171
+ processRequest: {
172
+ callback: async (next, event) => {
173
+ const result = await next();
174
+ // Only process buffer results (JSON responses)
175
+ if (!result || result.type !== 'buffer') {
176
+ return result;
177
+ }
178
+ const bufferResult = result;
179
+ const req = getExpressRequest(event.requestDigest.requestContext);
180
+ // Skip if no Express request or not a POST
181
+ if (!req || event.requestDigest.method !== 'POST') {
182
+ return result;
183
+ }
184
+ // Get request body for mutation detection
185
+ // grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer }
186
+ let body;
187
+ if (typeof event.requestDigest.getBody === 'function') {
188
+ try {
189
+ const rawBody = await event.requestDigest.getBody();
190
+ if (rawBody?.type === 'buffer' && rawBody.buffer) {
191
+ const jsonStr = rawBody.buffer.toString('utf8');
192
+ body = JSON.parse(jsonStr);
193
+ }
194
+ }
195
+ catch (e) {
196
+ log.debug('[auth-cookie] Failed to parse body from requestDigest');
197
+ }
198
+ }
199
+ body = body || req.body;
200
+ if (!body?.query) {
201
+ return result;
202
+ }
203
+ // Extract mutation names
204
+ const mutationNames = extractMutationNames(body.query);
205
+ if (mutationNames.length === 0) {
206
+ return result;
207
+ }
208
+ // Check for auth mutations
209
+ const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m));
210
+ const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m));
211
+ if (!signInMutation && !signOutMutation) {
212
+ return result;
213
+ }
214
+ log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`);
215
+ try {
216
+ // Parse response body
217
+ const payload = bufferResult.buffer.toString('utf8');
218
+ const graphqlResponse = JSON.parse(payload);
219
+ // Skip if there are GraphQL errors
220
+ if (graphqlResponse.errors?.length || !graphqlResponse.data) {
221
+ return result;
222
+ }
223
+ const data = graphqlResponse.data;
224
+ const authSettings = req.api?.authSettings;
225
+ const cookiesToSet = [];
226
+ // Handle sign-out mutations
227
+ if (signOutMutation && data[signOutMutation]) {
228
+ log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
229
+ const config = (0, cookie_1.getSessionCookieConfig)(authSettings);
230
+ cookiesToSet.push(serializeClearCookie(cookie_1.SESSION_COOKIE_NAME, config));
231
+ // Also clear device token on sign-out
232
+ const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
233
+ cookiesToSet.push(serializeClearCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
234
+ }
235
+ // Handle sign-in mutations
236
+ if (signInMutation) {
237
+ const accessToken = extractAccessToken(data, signInMutation);
238
+ if (accessToken) {
239
+ const rememberMe = hasRememberMe(body.variables);
240
+ const config = (0, cookie_1.getSessionCookieConfig)(authSettings, rememberMe);
241
+ log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`);
242
+ cookiesToSet.push(serializeCookie(cookie_1.SESSION_COOKIE_NAME, accessToken, config));
243
+ const deviceId = extractDeviceId(data, signInMutation);
244
+ if (deviceId) {
245
+ log.info('[auth-cookie] Device ID returned, setting device token cookie');
246
+ const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
247
+ cookiesToSet.push(serializeCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig));
248
+ }
249
+ }
250
+ }
251
+ // Set cookies directly on Express response and return modified headers
252
+ if (cookiesToSet.length > 0) {
253
+ const res = event.requestDigest.requestContext?.expressv4?.res;
254
+ if (res?.setHeader) {
255
+ // Get existing Set-Cookie headers from Express response
256
+ const existingCookies = res.getHeader('Set-Cookie');
257
+ const allCookies = [];
258
+ if (existingCookies) {
259
+ if (Array.isArray(existingCookies)) {
260
+ allCookies.push(...existingCookies);
261
+ }
262
+ else {
263
+ allCookies.push(existingCookies);
264
+ }
265
+ }
266
+ allCookies.push(...cookiesToSet);
267
+ // Set as array to get multiple Set-Cookie headers
268
+ res.setHeader('Set-Cookie', allCookies);
269
+ }
270
+ // Also update the BufferResult headers for grafserv to pass through
271
+ const existingBufferCookie = bufferResult.headers['set-cookie'];
272
+ const updatedHeaders = { ...bufferResult.headers };
273
+ // Remove set-cookie from grafserv headers since we set it on Express
274
+ delete updatedHeaders['set-cookie'];
275
+ return {
276
+ ...bufferResult,
277
+ headers: updatedHeaders,
278
+ };
279
+ }
280
+ }
281
+ catch (err) {
282
+ log.error('[auth-cookie] Error processing auth response:', err);
283
+ }
284
+ return result;
285
+ },
286
+ },
287
+ },
288
+ },
289
+ };
package/server.js CHANGED
@@ -4,10 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Server = exports.GraphQLServer = void 0;
7
+ const csrf_1 = require("@constructive-io/csrf");
7
8
  const graphql_env_1 = require("@constructive-io/graphql-env");
8
9
  const logger_1 = require("@pgpmjs/logger");
9
10
  const server_utils_1 = require("@pgpmjs/server-utils");
10
11
  const url_domains_1 = require("@constructive-io/url-domains");
12
+ const cookie_parser_1 = __importDefault(require("cookie-parser"));
11
13
  const express_1 = __importDefault(require("express"));
12
14
  const graphql_upload_1 = __importDefault(require("graphql-upload"));
13
15
  const graphile_cache_1 = require("graphile-cache");
@@ -27,7 +29,9 @@ const debug_db_1 = require("./middleware/observability/debug-db");
27
29
  const debug_memory_1 = require("./middleware/observability/debug-memory");
28
30
  const guard_1 = require("./middleware/observability/guard");
29
31
  const request_logger_1 = require("./middleware/observability/request-logger");
32
+ // Auth cookie handling is done via AuthCookiePlugin in grafserv
30
33
  const captcha_1 = require("./middleware/captcha");
34
+ const cookie_1 = require("./middleware/cookie");
31
35
  const upload_1 = require("./middleware/upload");
32
36
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
33
37
  const log = new logger_1.Logger('server');
@@ -127,6 +131,7 @@ class Server {
127
131
  }
128
132
  }
129
133
  app.use((0, server_utils_1.poweredBy)('constructive'));
134
+ app.use((0, cookie_parser_1.default)());
130
135
  app.use((0, cors_1.cors)(fallbackOrigin));
131
136
  app.use('/graphql', graphql_upload_1.default.graphqlUploadExpress({
132
137
  maxFileSize: 10 * 1024 * 1024, // 10 MB
@@ -141,6 +146,34 @@ class Server {
141
146
  app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
142
147
  app.use(authenticate);
143
148
  app.use((0, captcha_1.createCaptchaMiddleware)());
149
+ // CSRF protection for cookie-authenticated requests
150
+ // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
151
+ const csrf = (0, csrf_1.createCsrfMiddleware)({
152
+ cookieOptions: {
153
+ httpOnly: false, // SPA clients need to read this via document.cookie
154
+ secure: process.env.NODE_ENV === 'production',
155
+ sameSite: 'lax',
156
+ },
157
+ });
158
+ const csrfProtect = (req, res, next) => {
159
+ // Skip CSRF for Bearer token auth
160
+ const auth = req.headers.authorization;
161
+ if (auth?.toLowerCase().startsWith('bearer ')) {
162
+ return next();
163
+ }
164
+ // Skip if no session cookie (anonymous requests)
165
+ const sessionCookie = (0, cookie_1.parseCookieValue)(req, cookie_1.SESSION_COOKIE_NAME);
166
+ if (!sessionCookie) {
167
+ return next();
168
+ }
169
+ // Apply CSRF protection for cookie-authenticated requests
170
+ csrf.protect(req, res, next);
171
+ };
172
+ const csrfSetToken = (req, res, next) => {
173
+ csrf.setToken(req, res, next);
174
+ };
175
+ app.use(csrfSetToken); // Set CSRF token cookie on all requests
176
+ app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
144
177
  app.use((0, graphile_1.graphile)(effectiveOpts));
145
178
  app.use(flush_1.flush);
146
179
  // Error handling - MUST be LAST
package/types.d.ts CHANGED
@@ -29,6 +29,7 @@ export interface DatabaseSettings {
29
29
  enableLtree: boolean;
30
30
  enableLlm: boolean;
31
31
  enableRealtime: boolean;
32
+ enableBulk: boolean;
32
33
  }
33
34
  /**
34
35
  * Resolved pubkey challenge config from pubkey_settings typed table.
@@ -96,6 +97,8 @@ export interface AuthSettings {
96
97
  cookieHttponly?: boolean;
97
98
  cookieMaxAge?: string | null;
98
99
  cookiePath?: string;
100
+ /** Remember me duration (seconds) for extended session cookies */
101
+ rememberMeDuration?: string | null;
99
102
  /** reCAPTCHA / CAPTCHA */
100
103
  enableCaptcha?: boolean;
101
104
  captchaSiteKey?: string | null;