@constructive-io/graphql-server 4.17.0 → 4.18.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.
@@ -75,6 +75,34 @@ const RLS_MODULE_SQL = `
75
75
  WHERE api_id = $1 AND name = 'rls_module'
76
76
  LIMIT 1
77
77
  `;
78
+ /**
79
+ * Discover auth settings table location via public metaschema tables.
80
+ * Joins sessions_module with metaschema_public.schema to resolve
81
+ * the schema name + table name without touching private schemas.
82
+ */
83
+ const AUTH_SETTINGS_DISCOVERY_SQL = `
84
+ SELECT s.schema_name, sm.auth_settings_table AS table_name
85
+ FROM metaschema_modules_public.sessions_module sm
86
+ JOIN metaschema_public.schema s ON s.id = sm.schema_id
87
+ LIMIT 1
88
+ `;
89
+ /**
90
+ * Query auth settings from the discovered table.
91
+ * Schema and table name are resolved dynamically from metaschema modules.
92
+ */
93
+ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
94
+ SELECT
95
+ cookie_secure,
96
+ cookie_samesite,
97
+ cookie_domain,
98
+ cookie_httponly,
99
+ cookie_max_age,
100
+ cookie_path,
101
+ enable_captcha,
102
+ captcha_site_key
103
+ FROM "${schemaName}"."${tableName}"
104
+ LIMIT 1
105
+ `;
78
106
  // =============================================================================
79
107
  // Helpers
80
108
  // =============================================================================
@@ -127,7 +155,21 @@ const toRlsModule = (row) => {
127
155
  currentUserAgent: d.current_user_agent,
128
156
  };
129
157
  };
130
- const toApiStructure = (row, opts, rlsModuleRow) => ({
158
+ const toAuthSettings = (row) => {
159
+ if (!row)
160
+ return undefined;
161
+ return {
162
+ cookieSecure: row.cookie_secure,
163
+ cookieSamesite: row.cookie_samesite,
164
+ cookieDomain: row.cookie_domain,
165
+ cookieHttponly: row.cookie_httponly,
166
+ cookieMaxAge: row.cookie_max_age,
167
+ cookiePath: row.cookie_path,
168
+ enableCaptcha: row.enable_captcha,
169
+ captchaSiteKey: row.captcha_site_key,
170
+ };
171
+ };
172
+ const toApiStructure = (row, opts, rlsModuleRow, authSettingsRow) => ({
131
173
  apiId: row.api_id,
132
174
  dbname: row.dbname || opts.pg?.database || '',
133
175
  anonRole: row.anon_role || 'anon',
@@ -138,6 +180,7 @@ const toApiStructure = (row, opts, rlsModuleRow) => ({
138
180
  domains: [],
139
181
  databaseId: row.database_id,
140
182
  isPublic: row.is_public,
183
+ authSettings: toAuthSettings(authSettingsRow ?? null),
141
184
  });
142
185
  const createAdminStructure = (opts, schemas, databaseId) => ({
143
186
  dbname: opts.pg?.database ?? '',
@@ -172,6 +215,32 @@ const queryRlsModule = async (pool, apiId) => {
172
215
  const result = await pool.query(RLS_MODULE_SQL, [apiId]);
173
216
  return result.rows[0] ?? null;
174
217
  };
218
+ /**
219
+ * Load server-relevant auth settings from the tenant DB.
220
+ * Discovers the auth settings table dynamically by joining
221
+ * metaschema_modules_public.sessions_module with metaschema_public.schema
222
+ * (both public schemas). Fails gracefully if modules or table don't exist yet.
223
+ */
224
+ const queryAuthSettings = async (opts, dbname) => {
225
+ try {
226
+ const tenantPool = getPgPool({ ...opts.pg, database: dbname });
227
+ // Discover the auth settings schema + table name from public metaschema tables
228
+ const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
229
+ const resolved = discovery.rows[0];
230
+ if (!resolved) {
231
+ log.debug('[auth-settings] No sessions_module row found in tenant DB');
232
+ return null;
233
+ }
234
+ // Query the discovered auth settings table
235
+ const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
236
+ return result.rows[0] ?? null;
237
+ }
238
+ catch (e) {
239
+ // Table/module may not exist yet if the 2FA migration hasn't been applied
240
+ log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
241
+ return null;
242
+ }
243
+ };
175
244
  // =============================================================================
176
245
  // Resolution Logic
177
246
  // =============================================================================
@@ -223,8 +292,9 @@ const resolveApiNameHeader = async (ctx) => {
223
292
  return null;
224
293
  }
225
294
  const rlsModule = await queryRlsModule(pool, row.api_id);
226
- log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
227
- return toApiStructure(row, opts, rlsModule);
295
+ const authSettings = await queryAuthSettings(opts, row.dbname);
296
+ log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
297
+ return toApiStructure(row, opts, rlsModule, authSettings);
228
298
  };
229
299
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
230
300
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
@@ -239,8 +309,9 @@ const resolveDomainLookup = async (ctx) => {
239
309
  return null;
240
310
  }
241
311
  const rlsModule = await queryRlsModule(pool, row.api_id);
242
- log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
243
- return toApiStructure(row, opts, rlsModule);
312
+ const authSettings = await queryAuthSettings(opts, row.dbname);
313
+ log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
314
+ return toApiStructure(row, opts, rlsModule, authSettings);
244
315
  };
245
316
  const buildDevFallbackError = async (ctx, req) => {
246
317
  if (getNodeEnv() !== 'development')
@@ -5,6 +5,19 @@ import pgQueryContext from 'pg-query-context';
5
5
  import './types'; // for Request type
6
6
  const log = new Logger('auth');
7
7
  const isDev = () => getNodeEnv() === 'development';
8
+ /** Default cookie name for session tokens. */
9
+ const SESSION_COOKIE_NAME = 'constructive_session';
10
+ /**
11
+ * Extract a named cookie value from the raw Cookie header.
12
+ * Avoids pulling in cookie-parser as a dependency.
13
+ */
14
+ const parseCookieToken = (req, cookieName) => {
15
+ const header = req.headers.cookie;
16
+ if (!header)
17
+ return undefined;
18
+ const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
19
+ return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
20
+ };
8
21
  export const createAuthenticateMiddleware = (opts) => {
9
22
  return async (req, res, next) => {
10
23
  const api = req.api;
@@ -36,8 +49,14 @@ export const createAuthenticateMiddleware = (opts) => {
36
49
  let token = {};
37
50
  log.info(`[auth] authorization header present=${!!authorization}, ` +
38
51
  `authType=${authType ?? 'none'}, hasToken=${!!authToken}`);
39
- if (authType?.toLowerCase() === 'bearer' && authToken) {
40
- log.info('[auth] Processing bearer token authentication');
52
+ // Resolve the credential: prefer Bearer header, fall back to session cookie
53
+ const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
54
+ const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
55
+ ? authToken
56
+ : cookieToken;
57
+ const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');
58
+ if (effectiveToken) {
59
+ log.info(`[auth] Processing ${tokenSource} authentication`);
41
60
  const context = {
42
61
  'jwt.claims.ip_address': req.clientIp,
43
62
  };
@@ -54,7 +73,7 @@ export const createAuthenticateMiddleware = (opts) => {
54
73
  client: pool,
55
74
  context,
56
75
  query: authQuery,
57
- variables: [authToken],
76
+ variables: [effectiveToken],
58
77
  });
59
78
  log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
60
79
  if (result?.rowCount === 0) {
@@ -83,7 +102,7 @@ export const createAuthenticateMiddleware = (opts) => {
83
102
  }
84
103
  }
85
104
  else {
86
- log.info('[auth] No bearer token provided, using anonymous auth');
105
+ log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
87
106
  }
88
107
  req.token = token;
89
108
  }
@@ -0,0 +1,111 @@
1
+ import { Logger } from '@pgpmjs/logger';
2
+ import './types'; // for Request type
3
+ const log = new Logger('captcha');
4
+ /** Google reCAPTCHA verification endpoint */
5
+ const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
6
+ /**
7
+ * Header name the client sends the CAPTCHA response token in.
8
+ * Follows the common pattern: X-Captcha-Token.
9
+ */
10
+ const CAPTCHA_HEADER = 'x-captcha-token';
11
+ /**
12
+ * GraphQL mutation names that require CAPTCHA verification when enabled.
13
+ * Only sign-up and password-reset are gated; normal sign-in is not.
14
+ */
15
+ const CAPTCHA_PROTECTED_OPERATIONS = new Set([
16
+ 'signUp',
17
+ 'signUpWithMagicLink',
18
+ 'signUpWithSms',
19
+ 'resetPassword',
20
+ 'requestPasswordReset',
21
+ ]);
22
+ /**
23
+ * Attempt to extract the GraphQL operation name from the request body.
24
+ * Works for both JSON and already-parsed bodies.
25
+ */
26
+ const getOperationName = (req) => {
27
+ const body = req.body;
28
+ if (!body)
29
+ return undefined;
30
+ // Already parsed (express.json ran first)
31
+ if (typeof body === 'object' && body.operationName) {
32
+ return body.operationName;
33
+ }
34
+ return undefined;
35
+ };
36
+ /**
37
+ * Verify a reCAPTCHA token with Google's API.
38
+ */
39
+ const verifyToken = async (token, secretKey) => {
40
+ try {
41
+ const params = new URLSearchParams({ secret: secretKey, response: token });
42
+ const res = await fetch(RECAPTCHA_VERIFY_URL, {
43
+ method: 'POST',
44
+ body: params,
45
+ });
46
+ const data = (await res.json());
47
+ if (!data.success) {
48
+ log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
49
+ }
50
+ return data.success;
51
+ }
52
+ catch (e) {
53
+ log.error('[captcha] Error verifying token:', e.message);
54
+ return false;
55
+ }
56
+ };
57
+ /**
58
+ * Creates a CAPTCHA verification middleware.
59
+ *
60
+ * When `enable_captcha` is true in app_auth_settings, this middleware checks
61
+ * the X-Captcha-Token header on protected mutations (sign-up, password reset).
62
+ * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
63
+ * (the public site key is stored in app_auth_settings for the frontend).
64
+ *
65
+ * Skips verification when:
66
+ * - CAPTCHA is not enabled in auth settings
67
+ * - The request is not a protected mutation
68
+ * - No secret key is configured server-side
69
+ */
70
+ export const createCaptchaMiddleware = () => {
71
+ return async (req, res, next) => {
72
+ const authSettings = req.api?.authSettings;
73
+ // Skip if CAPTCHA is not enabled
74
+ if (!authSettings?.enableCaptcha) {
75
+ return next();
76
+ }
77
+ // Only gate protected operations
78
+ const opName = getOperationName(req);
79
+ if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
80
+ return next();
81
+ }
82
+ // Secret key must be set server-side (env var, not stored in DB for security)
83
+ const secretKey = process.env.RECAPTCHA_SECRET_KEY;
84
+ if (!secretKey) {
85
+ log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
86
+ return next();
87
+ }
88
+ const captchaToken = req.get(CAPTCHA_HEADER);
89
+ if (!captchaToken) {
90
+ res.status(200).json({
91
+ errors: [{
92
+ message: 'CAPTCHA verification required',
93
+ extensions: { code: 'CAPTCHA_REQUIRED' },
94
+ }],
95
+ });
96
+ return;
97
+ }
98
+ const valid = await verifyToken(captchaToken, secretKey);
99
+ if (!valid) {
100
+ res.status(200).json({
101
+ errors: [{
102
+ message: 'CAPTCHA verification failed',
103
+ extensions: { code: 'CAPTCHA_FAILED' },
104
+ }],
105
+ });
106
+ return;
107
+ }
108
+ log.info(`[captcha] Verified for operation=${opName}`);
109
+ next();
110
+ };
111
+ };
@@ -158,14 +158,25 @@ const buildPreset = (pool, schemas, anonRole, roleName) => {
158
158
  context['jwt.claims.user_agent'] = req.get('User-Agent');
159
159
  }
160
160
  if (req.token?.user_id) {
161
- return {
162
- pgSettings: {
163
- role: roleName,
164
- 'jwt.claims.token_id': req.token.id,
165
- 'jwt.claims.user_id': req.token.user_id,
166
- ...context,
167
- },
161
+ const pgSettings = {
162
+ role: roleName,
163
+ 'jwt.claims.token_id': req.token.id,
164
+ 'jwt.claims.user_id': req.token.user_id,
165
+ ...context,
168
166
  };
167
+ // Propagate credential metadata as JWT claims so PG functions
168
+ // can read them via current_setting('jwt.claims.access_level') etc.
169
+ if (req.token.access_level) {
170
+ pgSettings['jwt.claims.access_level'] = req.token.access_level;
171
+ }
172
+ if (req.token.kind) {
173
+ pgSettings['jwt.claims.kind'] = req.token.kind;
174
+ }
175
+ // Enforce read-only transactions for read_only credentials (API keys, etc.)
176
+ if (req.token.access_level === 'read_only') {
177
+ pgSettings['default_transaction_read_only'] = 'on';
178
+ }
179
+ return { pgSettings };
169
180
  }
170
181
  }
171
182
  return {
package/esm/server.js CHANGED
@@ -21,6 +21,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
21
21
  import { debugMemory } from './middleware/observability/debug-memory';
22
22
  import { localObservabilityOnly } from './middleware/observability/guard';
23
23
  import { createRequestLogger } from './middleware/observability/request-logger';
24
+ import { createCaptchaMiddleware } from './middleware/captcha';
24
25
  import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
25
26
  import { startDebugSampler } from './diagnostics/debug-sampler';
26
27
  const log = new Logger('server');
@@ -132,6 +133,7 @@ class Server {
132
133
  app.use(api);
133
134
  app.post('/upload', uploadAuthenticate, ...uploadRoute);
134
135
  app.use(authenticate);
136
+ app.use(createCaptchaMiddleware());
135
137
  app.use(graphile(effectiveOpts));
136
138
  app.use(flush);
137
139
  // Error handling - MUST be LAST
package/middleware/api.js CHANGED
@@ -81,6 +81,34 @@ const RLS_MODULE_SQL = `
81
81
  WHERE api_id = $1 AND name = 'rls_module'
82
82
  LIMIT 1
83
83
  `;
84
+ /**
85
+ * Discover auth settings table location via public metaschema tables.
86
+ * Joins sessions_module with metaschema_public.schema to resolve
87
+ * the schema name + table name without touching private schemas.
88
+ */
89
+ const AUTH_SETTINGS_DISCOVERY_SQL = `
90
+ SELECT s.schema_name, sm.auth_settings_table AS table_name
91
+ FROM metaschema_modules_public.sessions_module sm
92
+ JOIN metaschema_public.schema s ON s.id = sm.schema_id
93
+ LIMIT 1
94
+ `;
95
+ /**
96
+ * Query auth settings from the discovered table.
97
+ * Schema and table name are resolved dynamically from metaschema modules.
98
+ */
99
+ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
100
+ SELECT
101
+ cookie_secure,
102
+ cookie_samesite,
103
+ cookie_domain,
104
+ cookie_httponly,
105
+ cookie_max_age,
106
+ cookie_path,
107
+ enable_captcha,
108
+ captcha_site_key
109
+ FROM "${schemaName}"."${tableName}"
110
+ LIMIT 1
111
+ `;
84
112
  // =============================================================================
85
113
  // Helpers
86
114
  // =============================================================================
@@ -135,7 +163,21 @@ const toRlsModule = (row) => {
135
163
  currentUserAgent: d.current_user_agent,
136
164
  };
137
165
  };
138
- const toApiStructure = (row, opts, rlsModuleRow) => ({
166
+ const toAuthSettings = (row) => {
167
+ if (!row)
168
+ return undefined;
169
+ return {
170
+ cookieSecure: row.cookie_secure,
171
+ cookieSamesite: row.cookie_samesite,
172
+ cookieDomain: row.cookie_domain,
173
+ cookieHttponly: row.cookie_httponly,
174
+ cookieMaxAge: row.cookie_max_age,
175
+ cookiePath: row.cookie_path,
176
+ enableCaptcha: row.enable_captcha,
177
+ captchaSiteKey: row.captcha_site_key,
178
+ };
179
+ };
180
+ const toApiStructure = (row, opts, rlsModuleRow, authSettingsRow) => ({
139
181
  apiId: row.api_id,
140
182
  dbname: row.dbname || opts.pg?.database || '',
141
183
  anonRole: row.anon_role || 'anon',
@@ -146,6 +188,7 @@ const toApiStructure = (row, opts, rlsModuleRow) => ({
146
188
  domains: [],
147
189
  databaseId: row.database_id,
148
190
  isPublic: row.is_public,
191
+ authSettings: toAuthSettings(authSettingsRow ?? null),
149
192
  });
150
193
  const createAdminStructure = (opts, schemas, databaseId) => ({
151
194
  dbname: opts.pg?.database ?? '',
@@ -180,6 +223,32 @@ const queryRlsModule = async (pool, apiId) => {
180
223
  const result = await pool.query(RLS_MODULE_SQL, [apiId]);
181
224
  return result.rows[0] ?? null;
182
225
  };
226
+ /**
227
+ * Load server-relevant auth settings from the tenant DB.
228
+ * Discovers the auth settings table dynamically by joining
229
+ * metaschema_modules_public.sessions_module with metaschema_public.schema
230
+ * (both public schemas). Fails gracefully if modules or table don't exist yet.
231
+ */
232
+ const queryAuthSettings = async (opts, dbname) => {
233
+ try {
234
+ const tenantPool = (0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname });
235
+ // Discover the auth settings schema + table name from public metaschema tables
236
+ const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
237
+ const resolved = discovery.rows[0];
238
+ if (!resolved) {
239
+ log.debug('[auth-settings] No sessions_module row found in tenant DB');
240
+ return null;
241
+ }
242
+ // Query the discovered auth settings table
243
+ const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
244
+ return result.rows[0] ?? null;
245
+ }
246
+ catch (e) {
247
+ // Table/module may not exist yet if the 2FA migration hasn't been applied
248
+ log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
249
+ return null;
250
+ }
251
+ };
183
252
  // =============================================================================
184
253
  // Resolution Logic
185
254
  // =============================================================================
@@ -231,8 +300,9 @@ const resolveApiNameHeader = async (ctx) => {
231
300
  return null;
232
301
  }
233
302
  const rlsModule = await queryRlsModule(pool, row.api_id);
234
- log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
235
- return toApiStructure(row, opts, rlsModule);
303
+ const authSettings = await queryAuthSettings(opts, row.dbname);
304
+ log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
305
+ return toApiStructure(row, opts, rlsModule, authSettings);
236
306
  };
237
307
  const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
238
308
  return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
@@ -247,8 +317,9 @@ const resolveDomainLookup = async (ctx) => {
247
317
  return null;
248
318
  }
249
319
  const rlsModule = await queryRlsModule(pool, row.api_id);
250
- log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
251
- return toApiStructure(row, opts, rlsModule);
320
+ const authSettings = await queryAuthSettings(opts, row.dbname);
321
+ log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
322
+ return toApiStructure(row, opts, rlsModule, authSettings);
252
323
  };
253
324
  const buildDevFallbackError = async (ctx, req) => {
254
325
  if ((0, env_1.getNodeEnv)() !== 'development')
@@ -11,6 +11,19 @@ const pg_query_context_1 = __importDefault(require("pg-query-context"));
11
11
  require("./types"); // for Request type
12
12
  const log = new logger_1.Logger('auth');
13
13
  const isDev = () => (0, env_1.getNodeEnv)() === 'development';
14
+ /** Default cookie name for session tokens. */
15
+ const SESSION_COOKIE_NAME = 'constructive_session';
16
+ /**
17
+ * Extract a named cookie value from the raw Cookie header.
18
+ * Avoids pulling in cookie-parser as a dependency.
19
+ */
20
+ const parseCookieToken = (req, cookieName) => {
21
+ const header = req.headers.cookie;
22
+ if (!header)
23
+ return undefined;
24
+ const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
25
+ return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
26
+ };
14
27
  const createAuthenticateMiddleware = (opts) => {
15
28
  return async (req, res, next) => {
16
29
  const api = req.api;
@@ -42,8 +55,14 @@ const createAuthenticateMiddleware = (opts) => {
42
55
  let token = {};
43
56
  log.info(`[auth] authorization header present=${!!authorization}, ` +
44
57
  `authType=${authType ?? 'none'}, hasToken=${!!authToken}`);
45
- if (authType?.toLowerCase() === 'bearer' && authToken) {
46
- log.info('[auth] Processing bearer token authentication');
58
+ // Resolve the credential: prefer Bearer header, fall back to session cookie
59
+ const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
60
+ const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
61
+ ? authToken
62
+ : cookieToken;
63
+ const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');
64
+ if (effectiveToken) {
65
+ log.info(`[auth] Processing ${tokenSource} authentication`);
47
66
  const context = {
48
67
  'jwt.claims.ip_address': req.clientIp,
49
68
  };
@@ -60,7 +79,7 @@ const createAuthenticateMiddleware = (opts) => {
60
79
  client: pool,
61
80
  context,
62
81
  query: authQuery,
63
- variables: [authToken],
82
+ variables: [effectiveToken],
64
83
  });
65
84
  log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
66
85
  if (result?.rowCount === 0) {
@@ -89,7 +108,7 @@ const createAuthenticateMiddleware = (opts) => {
89
108
  }
90
109
  }
91
110
  else {
92
- log.info('[auth] No bearer token provided, using anonymous auth');
111
+ log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
93
112
  }
94
113
  req.token = token;
95
114
  }
@@ -0,0 +1,16 @@
1
+ import type { RequestHandler } from 'express';
2
+ import './types';
3
+ /**
4
+ * Creates a CAPTCHA verification middleware.
5
+ *
6
+ * When `enable_captcha` is true in app_auth_settings, this middleware checks
7
+ * the X-Captcha-Token header on protected mutations (sign-up, password reset).
8
+ * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
9
+ * (the public site key is stored in app_auth_settings for the frontend).
10
+ *
11
+ * Skips verification when:
12
+ * - CAPTCHA is not enabled in auth settings
13
+ * - The request is not a protected mutation
14
+ * - No secret key is configured server-side
15
+ */
16
+ export declare const createCaptchaMiddleware: () => RequestHandler;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCaptchaMiddleware = void 0;
4
+ const logger_1 = require("@pgpmjs/logger");
5
+ require("./types"); // for Request type
6
+ const log = new logger_1.Logger('captcha');
7
+ /** Google reCAPTCHA verification endpoint */
8
+ const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
9
+ /**
10
+ * Header name the client sends the CAPTCHA response token in.
11
+ * Follows the common pattern: X-Captcha-Token.
12
+ */
13
+ const CAPTCHA_HEADER = 'x-captcha-token';
14
+ /**
15
+ * GraphQL mutation names that require CAPTCHA verification when enabled.
16
+ * Only sign-up and password-reset are gated; normal sign-in is not.
17
+ */
18
+ const CAPTCHA_PROTECTED_OPERATIONS = new Set([
19
+ 'signUp',
20
+ 'signUpWithMagicLink',
21
+ 'signUpWithSms',
22
+ 'resetPassword',
23
+ 'requestPasswordReset',
24
+ ]);
25
+ /**
26
+ * Attempt to extract the GraphQL operation name from the request body.
27
+ * Works for both JSON and already-parsed bodies.
28
+ */
29
+ const getOperationName = (req) => {
30
+ const body = req.body;
31
+ if (!body)
32
+ return undefined;
33
+ // Already parsed (express.json ran first)
34
+ if (typeof body === 'object' && body.operationName) {
35
+ return body.operationName;
36
+ }
37
+ return undefined;
38
+ };
39
+ /**
40
+ * Verify a reCAPTCHA token with Google's API.
41
+ */
42
+ const verifyToken = async (token, secretKey) => {
43
+ try {
44
+ const params = new URLSearchParams({ secret: secretKey, response: token });
45
+ const res = await fetch(RECAPTCHA_VERIFY_URL, {
46
+ method: 'POST',
47
+ body: params,
48
+ });
49
+ const data = (await res.json());
50
+ if (!data.success) {
51
+ log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
52
+ }
53
+ return data.success;
54
+ }
55
+ catch (e) {
56
+ log.error('[captcha] Error verifying token:', e.message);
57
+ return false;
58
+ }
59
+ };
60
+ /**
61
+ * Creates a CAPTCHA verification middleware.
62
+ *
63
+ * When `enable_captcha` is true in app_auth_settings, this middleware checks
64
+ * the X-Captcha-Token header on protected mutations (sign-up, password reset).
65
+ * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
66
+ * (the public site key is stored in app_auth_settings for the frontend).
67
+ *
68
+ * Skips verification when:
69
+ * - CAPTCHA is not enabled in auth settings
70
+ * - The request is not a protected mutation
71
+ * - No secret key is configured server-side
72
+ */
73
+ const createCaptchaMiddleware = () => {
74
+ return async (req, res, next) => {
75
+ const authSettings = req.api?.authSettings;
76
+ // Skip if CAPTCHA is not enabled
77
+ if (!authSettings?.enableCaptcha) {
78
+ return next();
79
+ }
80
+ // Only gate protected operations
81
+ const opName = getOperationName(req);
82
+ if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
83
+ return next();
84
+ }
85
+ // Secret key must be set server-side (env var, not stored in DB for security)
86
+ const secretKey = process.env.RECAPTCHA_SECRET_KEY;
87
+ if (!secretKey) {
88
+ log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
89
+ return next();
90
+ }
91
+ const captchaToken = req.get(CAPTCHA_HEADER);
92
+ if (!captchaToken) {
93
+ res.status(200).json({
94
+ errors: [{
95
+ message: 'CAPTCHA verification required',
96
+ extensions: { code: 'CAPTCHA_REQUIRED' },
97
+ }],
98
+ });
99
+ return;
100
+ }
101
+ const valid = await verifyToken(captchaToken, secretKey);
102
+ if (!valid) {
103
+ res.status(200).json({
104
+ errors: [{
105
+ message: 'CAPTCHA verification failed',
106
+ extensions: { code: 'CAPTCHA_FAILED' },
107
+ }],
108
+ });
109
+ return;
110
+ }
111
+ log.info(`[captcha] Verified for operation=${opName}`);
112
+ next();
113
+ };
114
+ };
115
+ exports.createCaptchaMiddleware = createCaptchaMiddleware;
@@ -167,14 +167,25 @@ const buildPreset = (pool, schemas, anonRole, roleName) => {
167
167
  context['jwt.claims.user_agent'] = req.get('User-Agent');
168
168
  }
169
169
  if (req.token?.user_id) {
170
- return {
171
- pgSettings: {
172
- role: roleName,
173
- 'jwt.claims.token_id': req.token.id,
174
- 'jwt.claims.user_id': req.token.user_id,
175
- ...context,
176
- },
170
+ const pgSettings = {
171
+ role: roleName,
172
+ 'jwt.claims.token_id': req.token.id,
173
+ 'jwt.claims.user_id': req.token.user_id,
174
+ ...context,
177
175
  };
176
+ // Propagate credential metadata as JWT claims so PG functions
177
+ // can read them via current_setting('jwt.claims.access_level') etc.
178
+ if (req.token.access_level) {
179
+ pgSettings['jwt.claims.access_level'] = req.token.access_level;
180
+ }
181
+ if (req.token.kind) {
182
+ pgSettings['jwt.claims.kind'] = req.token.kind;
183
+ }
184
+ // Enforce read-only transactions for read_only credentials (API keys, etc.)
185
+ if (req.token.access_level === 'read_only') {
186
+ pgSettings['default_transaction_read_only'] = 'on';
187
+ }
188
+ return { pgSettings };
178
189
  }
179
190
  }
180
191
  return {
@@ -2,6 +2,8 @@ import type { ApiStructure } from '../types';
2
2
  export type ConstructiveAPIToken = {
3
3
  id?: string;
4
4
  user_id?: string;
5
+ access_level?: string;
6
+ kind?: string;
5
7
  [key: string]: unknown;
6
8
  };
7
9
  declare global {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.17.0",
3
+ "version": "4.18.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -41,38 +41,38 @@
41
41
  "backend"
42
42
  ],
43
43
  "dependencies": {
44
- "@constructive-io/graphql-env": "^3.5.4",
45
- "@constructive-io/graphql-types": "^3.4.4",
46
- "@constructive-io/s3-utils": "^2.10.2",
47
- "@constructive-io/upload-names": "^2.10.2",
48
- "@constructive-io/url-domains": "^2.10.2",
44
+ "@constructive-io/graphql-env": "^3.6.0",
45
+ "@constructive-io/graphql-types": "^3.5.0",
46
+ "@constructive-io/s3-utils": "^2.11.0",
47
+ "@constructive-io/upload-names": "^2.11.0",
48
+ "@constructive-io/url-domains": "^2.11.0",
49
49
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
50
50
  "@graphile/simplify-inflection": "8.0.0",
51
- "@pgpmjs/env": "^2.17.0",
52
- "@pgpmjs/logger": "^2.5.2",
53
- "@pgpmjs/server-utils": "^3.5.4",
54
- "@pgpmjs/types": "^2.21.0",
51
+ "@pgpmjs/env": "^2.18.0",
52
+ "@pgpmjs/logger": "^2.6.0",
53
+ "@pgpmjs/server-utils": "^3.6.0",
54
+ "@pgpmjs/types": "^2.22.0",
55
55
  "@pgsql/quotes": "^17.1.0",
56
56
  "cors": "^2.8.6",
57
57
  "deepmerge": "^4.3.1",
58
58
  "express": "^5.2.1",
59
- "gql-ast": "^3.4.2",
59
+ "gql-ast": "^3.5.0",
60
60
  "grafast": "1.0.0",
61
61
  "grafserv": "1.0.0",
62
62
  "graphile-build": "5.0.0",
63
63
  "graphile-build-pg": "5.0.0",
64
- "graphile-cache": "^3.4.4",
64
+ "graphile-cache": "^3.5.0",
65
65
  "graphile-config": "1.0.0",
66
- "graphile-settings": "^4.20.2",
66
+ "graphile-settings": "^4.21.0",
67
67
  "graphile-utils": "5.0.0",
68
68
  "graphql": "16.13.0",
69
69
  "graphql-upload": "^13.0.0",
70
70
  "lru-cache": "^11.2.7",
71
71
  "multer": "^2.1.1",
72
72
  "pg": "^8.20.0",
73
- "pg-cache": "^3.4.4",
74
- "pg-env": "^1.8.2",
75
- "pg-query-context": "^2.9.2",
73
+ "pg-cache": "^3.5.0",
74
+ "pg-env": "^1.9.0",
75
+ "pg-query-context": "^2.10.0",
76
76
  "pg-sql2": "5.0.0",
77
77
  "postgraphile": "5.0.0",
78
78
  "request-ip": "^3.3.0"
@@ -85,10 +85,10 @@
85
85
  "@types/multer": "^2.1.0",
86
86
  "@types/pg": "^8.18.0",
87
87
  "@types/request-ip": "^0.0.41",
88
- "graphile-test": "4.8.1",
88
+ "graphile-test": "4.9.0",
89
89
  "makage": "^0.3.0",
90
90
  "nodemon": "^3.1.14",
91
91
  "ts-node": "^10.9.2"
92
92
  },
93
- "gitHead": "d4ab16de23b2434617e820fcffaec0f008a7b6a0"
93
+ "gitHead": "1b3af3c5189b9ca2e765b9239a4b287099e64a03"
94
94
  }
package/server.js CHANGED
@@ -27,6 +27,7 @@ const debug_db_1 = require("./middleware/observability/debug-db");
27
27
  const debug_memory_1 = require("./middleware/observability/debug-memory");
28
28
  const guard_1 = require("./middleware/observability/guard");
29
29
  const request_logger_1 = require("./middleware/observability/request-logger");
30
+ const captcha_1 = require("./middleware/captcha");
30
31
  const upload_1 = require("./middleware/upload");
31
32
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
32
33
  const log = new logger_1.Logger('server');
@@ -139,6 +140,7 @@ class Server {
139
140
  app.use(api);
140
141
  app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
141
142
  app.use(authenticate);
143
+ app.use((0, captcha_1.createCaptchaMiddleware)());
142
144
  app.use((0, graphile_1.graphile)(effectiveOpts));
143
145
  app.use(flush_1.flush);
144
146
  // Error handling - MUST be LAST
package/types.d.ts CHANGED
@@ -38,6 +38,23 @@ export interface RlsModule {
38
38
  currentIpAddress: string;
39
39
  currentUserAgent: string;
40
40
  }
41
+ /**
42
+ * Server-visible subset of app_auth_settings (lives in the tenant DB private schema).
43
+ * Discovered dynamically via metaschema_modules_public.sessions_module.
44
+ * Loaded once per API resolution and cached alongside the ApiStructure.
45
+ */
46
+ export interface AuthSettings {
47
+ /** Cookie configuration */
48
+ cookieSecure?: boolean;
49
+ cookieSamesite?: string;
50
+ cookieDomain?: string | null;
51
+ cookieHttponly?: boolean;
52
+ cookieMaxAge?: string | null;
53
+ cookiePath?: string;
54
+ /** reCAPTCHA / CAPTCHA */
55
+ enableCaptcha?: boolean;
56
+ captchaSiteKey?: string | null;
57
+ }
41
58
  export interface ApiStructure {
42
59
  apiId?: string;
43
60
  dbname: string;
@@ -49,6 +66,7 @@ export interface ApiStructure {
49
66
  domains?: string[];
50
67
  databaseId?: string;
51
68
  isPublic?: boolean;
69
+ authSettings?: AuthSettings;
52
70
  }
53
71
  export type ApiError = {
54
72
  errorHtml: string;