diff --git a/src/client.ts b/src/client.ts index 8aa63f3..630b8cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -397,6 +397,7 @@ export function createClientFromRequest(request: Request): Base44Client { const serverUrlHeader = request.headers.get("Base44-Api-Url"); const functionsVersion = request.headers.get("Base44-Functions-Version"); const stateHeader = request.headers.get("Base44-State"); + const dataEnvHeader = request.headers.get("X-Data-Env"); if (!appId) { throw new Error( @@ -439,6 +440,16 @@ export function createClientFromRequest(request: Request): Base44Client { if (stateHeader) { additionalHeaders["Base44-State"] = stateHeader; } + // Propagate the data environment so entity operations from the function stay + // in the same environment (e.g. test data) as the triggering request. This + // matters for the user-scoped client: unlike the service token, the user JWT + // carries no data-env, so without forwarding this header the callbacks fall + // back to production data even when the app runs in test-data mode. + // Forward only the known closed set (matches the backend contract) rather + // than relaying an arbitrary attacker-supplied header value onward. + if (dataEnvHeader === "dev" || dataEnvHeader === "prod") { + additionalHeaders["X-Data-Env"] = dataEnvHeader; + } return createClient({ serverUrl: serverUrlHeader || "https://base44.app", diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 4a7b3ef..5ba2eba 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -540,6 +540,88 @@ describe('Service Role Authorization Headers', () => { expect(scope.isDone()).toBe(true); }); + test('should propagate X-Data-Env header on user-scoped API requests when created from request', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl, + 'X-Data-Env': 'dev' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + // The user-scoped client (not asServiceRole) must still carry the data env + // so test-mode function callbacks hit test data, not production. + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', 'dev') + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + + test('should not forward an X-Data-Env value outside the dev/prod set', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl, + 'X-Data-Env': 'evil' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', (val) => !val) // arbitrary value must not be relayed + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + + test('should not include X-Data-Env header when not present in original request', async () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-App-Id': appId, + 'Base44-Api-Url': serverUrl + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('X-Data-Env', (val) => !val) // Should not have this header + .matchHeader('Authorization', 'Bearer user-token-123') + .reply(200, { items: [], total: 0 }); + + await client.entities.Todo.list(); + + expect(scope.isDone()).toBe(true); + }); + test('should not include Base44-State header when not present in original request', async () => { const mockRequest = { headers: {