fix(achievements): redact hidden-locked entries from API response

Hidden Easter-eggs leaked their trigger semantics through the JSON
response: a curious user with DevTools open could read metric names
(nightOwlCount, leapDayCount, etc.), i18n keys (hiddenNightOwl,
hiddenLeapDay, etc.), icon names, and target values without unlocking
anything. The opaque-card UI was already correct, but the wire shape
spoiled the surprise.

Project hidden+locked entries to a sentinel shape before serialising
(achievements.hiddenCard.title key, HelpCircle icon, target 0,
metric totalTakenIntakes), and drop hidden-only metric counters from
the metrics block unless the matching achievement is unlocked. The
iOS branch reuses the same redacted view so native clients can't
short-circuit the surprise via the resolved-strings shape.

Once an Easter-egg unlocks the real fields ship — the unlock IS the
reveal moment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:56:46 +02:00
1 parent 3d585571d8
commit 545f44c074
2 files changed
+297 -4

No files matched your search

@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
passkey: { findMany: vi.fn() },
auditLog: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
userAchievement: {
findMany: vi.fn(),
createMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
locale: "en",
},
};
const HIDDEN_TRIGGER_STRINGS = [
"nightOwlCount",
"earlyBirdCount",
"leapDayCount",
"doctorPdfCount",
"localeFlipCount",
"hiddenNightOwl",
"hiddenEarlyBird",
"hiddenLeapDay",
"hiddenDoctorPdf",
"hiddenLocaleFlip",
"hiddenBugBuddy",
];
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/gamification/achievements — hidden achievement redaction", () => {
it("does NOT leak hidden trigger strings in the default JSON response when locked", async () => {
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const raw = await res.text();
for (const probe of HIDDEN_TRIGGER_STRINGS) {
expect(raw, `leak: ${probe}`).not.toContain(probe);
}
});
it("does NOT leak hidden trigger strings in the iOS response when locked", async () => {
const res = await GET(
new NextRequest(
"http://localhost/api/gamification/achievements?format=ios",
),
);
expect(res.status).toBe(200);
const raw = await res.text();
for (const probe of HIDDEN_TRIGGER_STRINGS) {
expect(raw, `iOS leak: ${probe}`).not.toContain(probe);
}
// Also assert the resolved description text is not in the body for
// a fresh user (the iOS branch resolves keys server-side).
expect(raw).not.toContain("between 02:00 and 04:00");
expect(raw).not.toContain("between 04:00 and 06:00");
expect(raw).not.toContain("February 29");
expect(raw).not.toContain("doctor report");
expect(raw).not.toContain("Switched the app language");
});
it("preserves hidden achievement entries in the response (count not zero) so the user knows hidden ones exist", async () => {
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
achievements: Array<{
id: string;
isHidden: boolean;
unlocked: boolean;
category: string;
}>;
};
};
const hiddenEntries = body.data.achievements.filter(
(a) => a.category === "hidden" || a.isHidden,
);
expect(hiddenEntries.length).toBeGreaterThan(0);
// every hidden+locked entry must be locked AND must have isHidden:true
for (const entry of hiddenEntries) {
expect(entry.isHidden).toBe(true);
expect(entry.unlocked).toBe(false);
}
});
it("hidden+locked entries do not include metric, titleKey, descriptionKey, or icon trigger names", async () => {
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
achievements: Array<{
id: string;
isHidden: boolean;
unlocked: boolean;
metric?: string;
titleKey?: string;
descriptionKey?: string;
icon?: string;
target?: number;
current?: number;
progressPercent?: number;
points?: number;
}>;
};
};
const hiddenLocked = body.data.achievements.filter(
(a) => a.isHidden && !a.unlocked,
);
expect(hiddenLocked.length).toBeGreaterThan(0);
for (const entry of hiddenLocked) {
// Trigger semantics scrubbed
expect(entry.metric ?? "").not.toMatch(
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
);
// i18n keys scrubbed
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
expect(entry.titleKey ?? "").not.toContain("hiddenEarlyBird");
expect(entry.titleKey ?? "").not.toContain("hiddenLeapDay");
expect(entry.titleKey ?? "").not.toContain("hiddenDoctorPdf");
expect(entry.titleKey ?? "").not.toContain("hiddenLocaleFlip");
expect(entry.titleKey ?? "").not.toContain("hiddenBugBuddy");
// Icon names that hint at the trigger scrubbed
expect(entry.icon ?? "").not.toMatch(/^(Moon|Sun|Sparkles|Languages)$/);
}
});
it("does not include hidden-only metric counters in the metrics block when hidden achievements are locked", async () => {
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
metrics: Record<string, unknown>;
};
};
expect(body.data.metrics).toBeDefined();
// Hidden-only metric names must not appear as keys
for (const probe of [
"nightOwlCount",
"earlyBirdCount",
"leapDayCount",
"doctorPdfCount",
"localeFlipCount",
]) {
expect(body.data.metrics).not.toHaveProperty(probe);
}
});
});
+91 -4
View File
@@ -9,6 +9,8 @@ import {
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
toBerlinDayKey,
type AchievementMetrics,
type AchievementProgress,
} from "@/lib/gamification/achievements";
import {
buildExpansionMetricValues,
@@ -835,6 +837,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
.filter((a) => !a.unlocked && !a.isHidden)
.sort((a, b) => b.progressPercent - a.progressPercent)[0] ?? null;
// v1.4.18 reconcile — redact hidden+locked entries before serialising
// so a curious user opening DevTools/Network can't read the trigger
// semantics from `metric`, `titleKey`, `descriptionKey`, `icon`,
// `target`, `current`, or `progressPercent`. The DOM render path
// already short-circuits on `isHidden && !unlocked`; this closes the
// wire-shape leak. Once an Easter-egg unlocks, the real fields ship
// (the unlock IS the moment to reveal the surprise).
const redactedAchievements = visibleAchievements.map((a) =>
redactIfHiddenLocked(a),
);
const redactedNextAchievement = nextAchievement
? redactIfHiddenLocked(nextAchievement)
: null;
const redactedMetrics = redactHiddenMetrics(
fullResult.metrics,
visibleAchievements,
);
const result = {
summary: {
unlockedCount: visibleUnlocked.length,
@@ -847,10 +867,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
: Math.round(
(visibleUnlocked.length / visibleAchievements.length) * 100,
),
nextAchievement,
nextAchievement: redactedNextAchievement,
},
achievements: visibleAchievements,
metrics: fullResult.metrics,
achievements: redactedAchievements,
metrics: redactedMetrics,
};
if (isIosFormat) {
@@ -859,7 +879,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
userLocale: user.locale,
});
const t = getServerTranslator(locale);
const ios: IosAchievement[] = result.achievements.map((a) => ({
// Same redaction principle: iOS clients receive opaque
// placeholders for hidden+locked entries. The native client
// mirrors the web-card opaque-placeholder UI when it sees the
// sentinel id `achievements.hiddenCard.title` come back.
const ios: IosAchievement[] = redactedAchievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
@@ -874,3 +898,66 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
/**
* Hidden Easter-eggs ship to the client *only* as opaque cards while
* locked. Returning the full `AchievementProgress` shape would let a
* snooper read the trigger semantics from `metric`, `titleKey`, etc.
* This helper projects locked+hidden entries down to the safe shape
* the UI's opaque-placeholder branch needs.
*
* Unlocked hidden achievements pass through unchanged — the unlock IS
* the reveal moment, so the toast and the unlocked card show the real
* strings.
*/
function redactIfHiddenLocked(
achievement: AchievementProgress,
): AchievementProgress {
if (!achievement.isHidden || achievement.unlocked) return achievement;
return {
id: achievement.id,
metric: "totalTakenIntakes",
category: "hidden",
titleKey: "achievements.hiddenCard.title",
descriptionKey: "achievements.hiddenCard.description",
icon: "HelpCircle",
format: "count",
target: 0,
current: 0,
points: achievement.points,
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: true,
};
}
/**
* Drop hidden-only metric counters from the response `metrics` block
* unless any hidden achievement they back is unlocked. The DOM never
* reads these counters today — the only consumer is the iOS client's
* progress bar — so removing the hidden ones from the wire is a pure
* spoiler shield with zero UI impact.
*/
function redactHiddenMetrics(
metrics: AchievementMetrics,
achievements: AchievementProgress[],
): Partial<AchievementMetrics> {
const HIDDEN_METRIC_KEYS = [
"nightOwlCount",
"earlyBirdCount",
"leapDayCount",
"doctorPdfCount",
"localeFlipCount",
] as const;
const safe: Partial<AchievementMetrics> = { ...metrics };
for (const key of HIDDEN_METRIC_KEYS) {
const backing = achievements.find(
(a) => a.isHidden && a.metric === key && a.unlocked,
);
if (!backing) {
delete safe[key];
}
}
return safe;
}