From da7fe063ed1cb78c29841a54933af512c19b1b06 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 7 Jul 2026 11:34:26 +0800 Subject: [PATCH] chore: consolidate env config --- .../__snapshots__/merge.test.ts.snap | 6 ++ graphql/env/__tests__/merge.test.ts | 6 +- .../src/middleware/__tests__/captcha.test.ts | 68 +++++++++++++++++++ graphql/server/src/middleware/captcha.ts | 16 +++-- graphql/server/src/server.ts | 7 +- graphql/types/src/constructive.ts | 6 ++ pgpm/env/README.md | 12 ++++ .../__snapshots__/merge.test.ts.snap | 6 ++ pgpm/env/__tests__/merge.test.ts | 14 +++- pgpm/env/src/env.ts | 10 +++ pgpm/types/src/pgpm.ts | 24 +++++++ 11 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 graphql/server/src/middleware/__tests__/captcha.test.ts diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index b6969aaf51..b32a807b63 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -17,6 +17,9 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and ], "roleName": "env_role", }, + "captcha": { + "recaptchaSecretKey": "test-recaptcha-secret", + }, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -120,5 +123,8 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "port": 587, "secure": false, }, + "upload": { + "maxFileSize": 123456, + }, } `; diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index edbc3f6df6..a2e96d1e74 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -52,7 +52,9 @@ describe('getEnvOptions', () => { API_META_SCHEMAS: 'env_meta1,env_meta2', API_ANON_ROLE: 'env_anon', API_ROLE_NAME: 'env_role', - API_DEFAULT_DATABASE_ID: 'env_db' + API_DEFAULT_DATABASE_ID: 'env_db', + RECAPTCHA_SECRET_KEY: 'test-recaptcha-secret', + MAX_UPLOAD_FILE_SIZE: '123456' }; const result = getEnvOptions( @@ -82,6 +84,8 @@ describe('getEnvOptions', () => { ); expect(result).toMatchSnapshot(); + expect(result.captcha?.recaptchaSecretKey).toBe('test-recaptcha-secret'); + expect(result.upload?.maxFileSize).toBe(123456); }); it('replaces graphql array fields with later values (overrides win)', () => { diff --git a/graphql/server/src/middleware/__tests__/captcha.test.ts b/graphql/server/src/middleware/__tests__/captcha.test.ts new file mode 100644 index 0000000000..12410d023a --- /dev/null +++ b/graphql/server/src/middleware/__tests__/captcha.test.ts @@ -0,0 +1,68 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { createCaptchaMiddleware } from '../captcha'; + +const originalFetch = global.fetch; +const originalRecaptchaSecretKey = process.env.RECAPTCHA_SECRET_KEY; + +const createReq = (overrides: Partial = {}): Request => ({ + api: { + authSettings: { + enableCaptcha: true, + }, + }, + body: { + operationName: 'signUp', + }, + get: jest.fn((name: string) => (name.toLowerCase() === 'x-captcha-token' ? 'token-123' : undefined)), + ...overrides, +} as unknown as Request); + +const createRes = (): Response => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), +} as unknown as Response); + +describe('createCaptchaMiddleware', () => { + afterEach(() => { + global.fetch = originalFetch; + if (originalRecaptchaSecretKey === undefined) { + delete process.env.RECAPTCHA_SECRET_KEY; + } else { + process.env.RECAPTCHA_SECRET_KEY = originalRecaptchaSecretKey; + } + jest.restoreAllMocks(); + }); + + it('does not read RECAPTCHA_SECRET_KEY directly from process.env', async () => { + process.env.RECAPTCHA_SECRET_KEY = 'env-secret'; + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + + const next = jest.fn() as NextFunction; + await createCaptchaMiddleware()(createReq(), createRes(), next); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('uses the configured reCAPTCHA secret key when verifying a token', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + json: async () => ({ success: true }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const next = jest.fn() as NextFunction; + await createCaptchaMiddleware({ recaptchaSecretKey: 'configured-secret' })( + createReq(), + createRes(), + next, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = fetchMock.mock.calls[0][1]?.body as URLSearchParams; + expect(body.get('secret')).toBe('configured-secret'); + expect(body.get('response')).toBe('token-123'); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graphql/server/src/middleware/captcha.ts b/graphql/server/src/middleware/captcha.ts index 60f7f90fd0..1ebfcd260f 100644 --- a/graphql/server/src/middleware/captcha.ts +++ b/graphql/server/src/middleware/captcha.ts @@ -25,6 +25,10 @@ const CAPTCHA_PROTECTED_OPERATIONS = new Set([ 'requestPasswordReset', ]); +export interface CaptchaMiddlewareOptions { + recaptchaSecretKey?: string; +} + interface RecaptchaResponse { success: boolean; 'error-codes'?: string[]; @@ -70,7 +74,7 @@ const verifyToken = async (token: string, secretKey: string): Promise = * * When `enable_captcha` is true in app_auth_settings, this middleware checks * the X-Captcha-Token header on protected mutations (sign-up, password reset). - * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable + * The secret key is passed from server configuration * (the public site key is stored in app_auth_settings for the frontend). * * Skips verification when: @@ -78,7 +82,9 @@ const verifyToken = async (token: string, secretKey: string): Promise = * - The request is not a protected mutation * - No secret key is configured server-side */ -export const createCaptchaMiddleware = (): RequestHandler => { +export const createCaptchaMiddleware = ( + opts: CaptchaMiddlewareOptions = {}, +): RequestHandler => { return async (req: Request, res: Response, next: NextFunction): Promise => { const authSettings = req.api?.authSettings; @@ -93,10 +99,10 @@ export const createCaptchaMiddleware = (): RequestHandler => { return next(); } - // Secret key must be set server-side (env var, not stored in DB for security) - const secretKey = process.env.RECAPTCHA_SECRET_KEY; + // Secret key must be set server-side (config/env, not stored in DB for security) + const secretKey = opts.recaptchaSecretKey; if (!secretKey) { - log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification'); + log.warn('[captcha] enable_captcha is true but no reCAPTCHA secret key is configured; skipping verification'); return next(); } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index a88b48d9f4..fb11119845 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -151,8 +151,9 @@ class Server { app.use(poweredBy('constructive')); app.use(cookieParser()); app.use(cors(fallbackOrigin)); + const uploadMaxFileSize = effectiveOpts.upload?.maxFileSize ?? 10 * 1024 * 1024; app.use('/graphql', graphqlUpload.graphqlUploadExpress({ - maxFileSize: 10 * 1024 * 1024, // 10 MB + maxFileSize: uploadMaxFileSize, maxFiles: 10, })); @@ -165,7 +166,9 @@ class Server { app.use(api); app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg })); - app.use(createCaptchaMiddleware()); + app.use(createCaptchaMiddleware({ + recaptchaSecretKey: effectiveOpts.captcha?.recaptchaSecretKey, + })); // CSRF protection for cookie-authenticated requests // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index fc64c547c0..1f9166c1a0 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -7,6 +7,8 @@ import { DeploymentOptions, ServerOptions, CDNOptions, + CaptchaOptions, + UploadOptions, MigrationOptions, JobsConfig } from '@pgpmjs/types'; @@ -51,6 +53,10 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt api?: ApiOptions; /** CDN and file storage configuration */ cdn?: CDNOptions; + /** CAPTCHA verification configuration */ + captcha?: CaptchaOptions; + /** GraphQL upload configuration */ + upload?: UploadOptions; /** Module deployment configuration */ deployment?: DeploymentOptions; /** Migration and code generation options */ diff --git a/pgpm/env/README.md b/pgpm/env/README.md index bc41f0aa9d..4fdc1b0995 100644 --- a/pgpm/env/README.md +++ b/pgpm/env/README.md @@ -28,3 +28,15 @@ import { getEnvOptions } from '@pgpmjs/env'; const options = getEnvOptions(overrides, cwd); ``` + +## Environment Variables + +Selected server/runtime variables parsed by this package: + +| Variable | Option | +| --- | --- | +| `OAUTH_STATE_SECRET` | `oauth.stateSecret` | +| `RECAPTCHA_SECRET_KEY` | `captcha.recaptchaSecretKey` | +| `MAX_UPLOAD_FILE_SIZE` | `upload.maxFileSize` | + +`RECAPTCHA_SECRET_KEY` is the server-side secret used to verify CAPTCHA tokens. `MAX_UPLOAD_FILE_SIZE` is parsed as bytes and controls GraphQL multipart upload limits. diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index 8ba29ba739..4d0e550241 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -2,6 +2,9 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` { + "captcha": { + "recaptchaSecretKey": "test-recaptcha-secret", + }, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -99,5 +102,8 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "port": 587, "secure": false, }, + "upload": { + "maxFileSize": 123456, + }, } `; diff --git a/pgpm/env/__tests__/merge.test.ts b/pgpm/env/__tests__/merge.test.ts index c880841c58..0766f08c3c 100644 --- a/pgpm/env/__tests__/merge.test.ts +++ b/pgpm/env/__tests__/merge.test.ts @@ -154,7 +154,9 @@ describe('getEnvOptions', () => { PORT: '7777', DEPLOYMENT_FAST: 'false', JOBS_SUPPORT_ANY: 'false', - JOBS_SUPPORTED: 'alpha,beta' + JOBS_SUPPORTED: 'alpha,beta', + RECAPTCHA_SECRET_KEY: 'test-recaptcha-secret', + MAX_UPLOAD_FILE_SIZE: '123456' }; const result = getEnvOptions( @@ -178,6 +180,8 @@ describe('getEnvOptions', () => { ); expect(result).toMatchSnapshot(); + expect(result.captcha?.recaptchaSecretKey).toBe('test-recaptcha-secret'); + expect(result.upload?.maxFileSize).toBe(123456); }); it('replaces array fields with later values (overrides win)', () => { @@ -225,4 +229,12 @@ describe('getEnvOptions', () => { expect(result.jobs?.scheduler?.supported).toEqual(['gamma', 'zeta']); expect(result.packages).toEqual(['testing/*', 'extensions/*']); }); + + it('ignores invalid MAX_UPLOAD_FILE_SIZE values', () => { + const result = getEnvOptions({}, process.cwd(), { + MAX_UPLOAD_FILE_SIZE: 'not-a-number' + }); + + expect(result.upload?.maxFileSize).toBe(pgpmDefaults.upload?.maxFileSize); + }); }); diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index 6423c059d4..72d4056dac 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -62,6 +62,9 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => CDN_ENDPOINT, CDN_PUBLIC_URL_PREFIX, + RECAPTCHA_SECRET_KEY, + MAX_UPLOAD_FILE_SIZE, + DEPLOYMENT_USE_TX, DEPLOYMENT_FAST, DEPLOYMENT_USE_PLAN, @@ -100,6 +103,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => SMTP_LOGGER, SMTP_DEBUG } = env; + const maxUploadFileSize = parseEnvNumber(MAX_UPLOAD_FILE_SIZE); return { db: { @@ -155,6 +159,12 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(CDN_ENDPOINT && { endpoint: CDN_ENDPOINT }), ...(CDN_PUBLIC_URL_PREFIX && { publicUrlPrefix: CDN_PUBLIC_URL_PREFIX }), }, + captcha: { + ...(RECAPTCHA_SECRET_KEY && { recaptchaSecretKey: RECAPTCHA_SECRET_KEY }), + }, + upload: { + ...(maxUploadFileSize !== undefined && { maxFileSize: maxUploadFileSize }), + }, deployment: { ...(DEPLOYMENT_USE_TX && { useTx: parseEnvBoolean(DEPLOYMENT_USE_TX) }), ...(DEPLOYMENT_FAST && { fast: parseEnvBoolean(DEPLOYMENT_FAST) }), diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index 4a277d5b4a..54dcb8861e 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -127,6 +127,22 @@ export interface CDNOptions { publicUrlPrefix?: string; } +/** + * CAPTCHA verification configuration + */ +export interface CaptchaOptions { + /** Secret key used by the server to verify reCAPTCHA tokens */ + recaptchaSecretKey?: string; +} + +/** + * GraphQL upload configuration + */ +export interface UploadOptions { + /** Maximum accepted GraphQL upload file size in bytes */ + maxFileSize?: number; +} + /** * SMTP email configuration options */ @@ -245,6 +261,10 @@ export interface PgpmOptions { server?: ServerOptions; /** CDN and file storage configuration */ cdn?: CDNOptions; + /** CAPTCHA verification configuration */ + captcha?: CaptchaOptions; + /** GraphQL upload configuration */ + upload?: UploadOptions; /** Module deployment configuration */ deployment?: DeploymentOptions; /** Migration and code generation options */ @@ -306,6 +326,10 @@ export const pgpmDefaults: PgpmOptions = { endpoint: 'http://localhost:9000', publicUrlPrefix: 'http://localhost:9000' }, + captcha: {}, + upload: { + maxFileSize: 10 * 1024 * 1024 + }, deployment: { useTx: true, fast: false,