From db5a6fbe5284b49f0a9175cc68f7fb18e4286ff0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 09:54:56 +0800 Subject: [PATCH] fix: restrict dynamic journey responses --- .../lib/birth-time-journey-response-schema.ts | 10 ++- .../src/lib/birth-time-journey-response.ts | 8 +-- frontend/tests/birth-time-dynamic-api.test.ts | 63 +++++++++++++++++-- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/birth-time-journey-response-schema.ts b/frontend/src/lib/birth-time-journey-response-schema.ts index fa587d24..44fb759a 100644 --- a/frontend/src/lib/birth-time-journey-response-schema.ts +++ b/frontend/src/lib/birth-time-journey-response-schema.ts @@ -108,9 +108,13 @@ function dynamicActionMatchesPhase(value: { const dynamicJourneyResponseSchema = z.object({ ...responseCore, - scoring: versionedScoringSchema.nullable(), - answers: z.record(answerSchema).readonly(), - lifeEvents: z.array(lifeEventSchema).readonly(), + questionnaire: z.null(), + scoring: z.null(), + answers: z.record(answerSchema).refine( + (answers) => Object.keys(answers).length === 0, + "dynamic answers are server-private", + ).readonly(), + lifeEvents: z.array(lifeEventSchema).max(0, "dynamic life events are server-private").readonly(), candidateResult: candidateResultSchema.nullable(), journeyProtocol: z.literal("dynamic-choice-v2"), turnVersion: z.number().int().nonnegative(), diff --git a/frontend/src/lib/birth-time-journey-response.ts b/frontend/src/lib/birth-time-journey-response.ts index cda4bece..953c0cee 100644 --- a/frontend/src/lib/birth-time-journey-response.ts +++ b/frontend/src/lib/birth-time-journey-response.ts @@ -140,10 +140,10 @@ export function storedDynamicJourneyResponse( return { caseId: stored.id, snapshot: stored.snapshot, - questionnaire: stored.questionnaire, - scoring: stored.scoring ?? null, - answers: stored.answers, - lifeEvents: stored.lifeEvents ?? [], + questionnaire: null, + scoring: null, + answers: {}, + lifeEvents: [], candidateResult: stored.candidateResult ?? null, evidenceDraft: null, ...stored.dynamicTurnState, diff --git a/frontend/tests/birth-time-dynamic-api.test.ts b/frontend/tests/birth-time-dynamic-api.test.ts index e987071d..6d4f5862 100644 --- a/frontend/tests/birth-time-dynamic-api.test.ts +++ b/frontend/tests/birth-time-dynamic-api.test.ts @@ -40,15 +40,66 @@ test("unmatched context is optional, trimmed, and bounded", () => { assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, partitionId: "private" }).success, false); }); -test("dynamic responses preserve the protocol discriminant without private scoring data", () => { - const response = storedDynamicJourneyResponse(dynamicCase()); +test("dynamic responses project one exact public shape", () => { + const stored = { + ...dynamicCase(), + questionnaire: { questions: [], samples: [], raw: { candidateVector: [0.4, 0.6] } }, + scoring: { + answeredCount: 1, + candidateClusterRankings: [{ cluster: "05:00", score: 9 }], + raw: { candidateVector: [0.4, 0.6] }, + nextRound: null, + nextRoundQuestions: [], + }, + answers: { legacy_private_answer: "A" as const }, + lifeEvents: [{ + id: "ec4ab5f0-829c-468a-90ea-42842f9f70d3", + domain: "career" as const, + precision: "year" as const, + date: "2020", + }], + }; + const response = storedDynamicJourneyResponse(stored); const parsed = parseJourneyResponse(response); + assert.deepEqual(Object.keys(parsed).sort(), [ + "answers", "candidateResult", "caseId", "evidenceDraft", "journeyProtocol", + "lifeEvents", "nextAction", "permissions", "progress", "questionnaire", + "scoring", "snapshot", "turnVersion", + ]); assert.equal(parsed.journeyProtocol, "dynamic-choice-v2"); - assert.equal(parsed.nextAction.kind, "ask_dynamic_choice"); - const serialized = JSON.stringify(parsed); - assert.doesNotMatch(serialized, /partitionId|candidateScores|agentContext|candidateModel/); - assert.throws(() => parseJourneyResponse({ ...response, partitionId: "forged" })); + assert.deepEqual({ + questionnaire: parsed.questionnaire, + scoring: parsed.scoring, + answers: parsed.answers, + lifeEvents: parsed.lifeEvents, + }, { questionnaire: null, scoring: null, answers: {}, lifeEvents: [] }); +}); + +test("dynamic response parsing rejects private legacy and candidate payloads", () => { + const response = storedDynamicJourneyResponse(dynamicCase()); + const scoring = { + answeredCount: 1, + candidateClusterRankings: [{ cluster: "05:00", score: 9 }], + raw: { candidateVectors: { "05:00": [0.4, 0.6] } }, + nextRound: null, + nextRoundQuestions: [], + }; + const privatePayloads = [ + { ...response, scoring }, + { ...response, questionnaire: { questions: [], samples: [], raw: { candidateVectors: [1] } } }, + { ...response, answers: { hidden: "A" } }, + { ...response, lifeEvents: [{ id: "ec4ab5f0-829c-468a-90ea-42842f9f70d3", domain: "career", precision: "year", date: "2020" }] }, + { ...response, candidateModel: { candidates: ["05:10"] } }, + { ...response, currentChoiceQuestion: persistedQuestion }, + { ...response, partitionId: "window-a" }, + { ...response, nextAction: { ...response.nextAction, question: { + ...persistedQuestion, + options: persistedQuestion.options, + } } }, + ]; + + for (const payload of privatePayloads) assert.throws(() => parseJourneyResponse(payload)); }); test("dynamic routes authenticate before parsing and dispatch scoped methods", () => {