fix: [Backport] client environment api sdk fixes (#8074)

This commit is contained in:
Anshuman Pandey
2026-05-21 10:46:53 +05:30
committed by GitHub
parent 74e0fba757
commit 5a1fc01388
3 changed files with 71 additions and 28 deletions
@@ -103,6 +103,7 @@ describe("getWorkspaceStateData", () => {
id: workspaceId,
appSetupCompleted: true,
workspaceSettings: {
id: workspaceId,
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
@@ -111,7 +112,14 @@ describe("getWorkspaceStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
surveys: mockWorkspaceData.surveys,
// `survey.name` is replaced with a back-compat placeholder; segment was
// null in the mock so the sanitized segment stays null.
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
},
],
actionClasses: mockWorkspaceData.actionClasses,
});
@@ -211,6 +219,7 @@ describe("getWorkspaceStateData", () => {
const result = await getWorkspaceStateData(workspaceId);
expect(result.workspace.workspaceSettings).toEqual({
id: workspaceId,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
@@ -42,6 +42,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: { id: workspaceId },
select: {
id: true,
legacyEnvironmentId: true,
appSetupCompleted: true,
recontactDays: true,
clickOutsideClose: true,
@@ -72,7 +73,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
select: {
id: true,
welcomeCard: true,
// name intentionally omitted — internal label not needed by the SDK
// `name` deliberately not selected — internal label not needed by the
// SDK and replaced with a fixed placeholder below so older SDKs that
// decoded `Survey.name` as a required field keep working.
questions: true,
blocks: true,
variables: true,
@@ -99,9 +102,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
// Only need to know if any filters exist so we can compute
// `hasFilters`. Real filter values, segment title/description, and
// surveys-list relation are never exposed to clients.
segment: {
select: {
id: true,
@@ -135,17 +138,46 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
throw new ResourceNotFoundError("workspace", workspaceId);
}
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
// Backwards-compat response shape for SDKs from before PR #7931. Those
// clients decoded `survey.name` and the full `segment` object as required
// fields, so the response must still carry that shape — but every field
// that could leak sensitive targeting data is replaced with a placeholder.
// The actual segment-membership check happens server-side (segment IDs in
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
//
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
// the `/me` endpoints' pattern so migrated workspaces keep returning the
// original env ID older clients persisted.
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
const placeholderDate = new Date(0);
const placeholderFilter = {
id: "placeholder",
connector: null,
resource: {
id: "placeholder",
root: { type: "device", deviceType: "phone" },
value: "deprecated",
qualifier: { operator: "equals" },
},
};
const transformedSurveys = workspaceData.surveys.map((survey) => {
const minimalSegment = survey.segment
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
const sanitizedSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
title: "[deprecated] segment title omitted from public API - will be removed soon",
description: null,
isPrivate: true,
filters: realHasFilters ? [placeholderFilter] : [],
environmentId: legacyOrCurrentId,
workspaceId: legacyOrCurrentId,
createdAt: placeholderDate,
updatedAt: placeholderDate,
surveys: [],
hasFilters: realHasFilters,
}
: null;
@@ -155,7 +187,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});
return { ...transformed, segment: minimalSegment };
return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
};
});
return {
@@ -163,6 +199,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
id: workspaceData.id,
appSetupCompleted: workspaceData.appSetupCompleted,
workspaceSettings: {
id: workspaceData.id,
recontactDays: workspaceData.recontactDays,
clickOutsideClose: workspaceData.clickOutsideClose,
overlay: workspaceData.overlay,
@@ -171,7 +208,11 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
styling: resolveStorageUrlsInObject(workspaceData.styling),
},
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
// The runtime shape carries extra back-compat fields (placeholder
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
// intentional and only this endpoint's response widens the type.
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
actionClasses: workspaceData.actionClasses,
};
} catch (error) {
+6 -13
View File
@@ -1,14 +1,13 @@
import { z } from "zod";
import { ZActionClass } from "./action-classes";
import { ZId } from "./common";
import { ZJsWorkspaceStateSegment } from "./segment";
import { ZUploadFileConfig } from "./storage";
import { ZSurveyBase, surveyRefinement } from "./surveys/types";
import { ZWorkspace } from "./workspace";
export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
id: true,
// name intentionally omitted — internal label, not needed by SDK
name: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -20,7 +19,7 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
autoClose: true,
styling: true,
status: true,
// segment intentionally omitted from pick — replaced with minimal shape below
segment: true,
recontactDays: true,
displayLimit: true,
displayOption: true,
@@ -32,16 +31,9 @@ export const ZJsWorkspaceStateSurvey = ZSurveyBase.pick({
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
recaptcha: true,
})
.extend({
// Only expose what the SDK needs: segment ID for membership check + whether any filters exist.
// Full filter logic (titles, descriptions, conditions) is evaluated server-side and must not
// be sent to the browser to avoid leaking sensitive targeting data.
segment: ZJsWorkspaceStateSegment.nullable(),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
}).superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
export type TJsWorkspaceStateSurvey = z.infer<typeof ZJsWorkspaceStateSurvey>;
@@ -56,6 +48,7 @@ export const ZJsWorkspaceStateActionClass = ZActionClass.pick({
export type TJsWorkspaceStateActionClass = z.infer<typeof ZJsWorkspaceStateActionClass>;
export const ZJsWorkspaceStateWorkspaceSetting = ZWorkspace.pick({
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,