merge(reconcile): g1a-cycleop audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 01:49:20 +02:00
commit 6a96accc51
11 files changed
+262 -55

No files matched your search

+10 -7
View File
@@ -10,18 +10,21 @@ import { CycleView } from "@/components/cycle/cycle-view";
/**
* v1.15.0 — the cycle vertical entry.
*
* Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved
* server-side from gender + the per-user opt-in). An unauthenticated visitor
* is bounced to login; an authenticated account without the feature enabled
* is bounced home (the nav entry is already hidden for them, so this only
* catches a direct URL hit). Every `/api/cycle/*` route also enforces the
* gate server-side, so this is a UX redirect, not the security boundary.
* Gated on the resolved `modules.cycle` flag from `GET /api/auth/me`
* (v1.18.0 — the per-user toggle AND the operator server-wide kill-switch;
* the operator-unaware `cycleTrackingEnabled` field is NOT used here so an
* operator-disabled instance bounces a direct URL hit too). An
* unauthenticated visitor is bounced to login; an authenticated account
* without the module enabled is bounced home (the nav entry is already
* hidden for them, so this only catches a direct URL hit). Every
* `/api/cycle/*` route also enforces the gate server-side, so this is a UX
* redirect, not the security boundary.
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.cycleTrackingEnabled === true;
const enabled = user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
+7 -2
View File
@@ -404,11 +404,16 @@ export default function InsightsPage() {
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined}
// v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
// AND the operator server-wide kill-switch) so an operator-off
// instance never renders the ring nor fires its calendar read.
extraTile={
user?.modules?.cycle === true ? <CycleRingTile /> : undefined
}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
hideStrain={user?.cycleTrackingEnabled === true}
hideStrain={user?.modules?.cycle === true}
/>
),
"daily-briefing": flags.briefing ? (
@@ -27,6 +27,16 @@ vi.mock("@/lib/insights/features", () => ({
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
// v1.18.0 — the coach cycle block gates through
// `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it
// with the same resolved map the tests drive via `resolveModuleMap`.
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
const key = args[1];
const map = await (resolveModuleMap as unknown as () => Promise<
Record<ModuleKey, boolean>
>)();
return map[key] !== false;
}),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
@@ -38,6 +38,16 @@ const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
// v1.18.0 — the coach cycle block gates through
// `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it
// with the same resolved map the tests drive via `resolveModuleMap`.
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
const key = args[1];
const map = await (resolveModuleMap as unknown as () => Promise<
Record<ModuleKey, boolean>
>)();
return map[key] !== false;
}),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
@@ -37,6 +37,18 @@ const allModulesEnabled = (): Record<ModuleKey, boolean> =>
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
// v1.18.0 — the coach cycle block now gates through
// `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back the
// mock with the same resolved map the tests already drive via
// `resolveModuleMap`, so flipping `cycle` off in a fixture also closes the
// cycle block (operator/user kill-switch parity) without a DB read.
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
const key = args[1];
const map = await (resolveModuleMap as unknown as () => Promise<
Record<ModuleKey, boolean>
>)();
return map[key] !== false;
}),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
+10 -8
View File
@@ -47,7 +47,7 @@ import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
buildComplianceLedgerRows,
@@ -177,8 +177,9 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
* build off the sleep signal alone.
*
* `cycle` is intentionally absent: its block already resolves through the
* single-source-of-truth cycle gate (`isCycleEnabled`) below, exactly as
* the W1 foundation prescribes. `coach` is the surface being narrated, not
* fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user
* toggle AND the operator server-wide kill-switch) below, exactly as the
* W1 foundation prescribes. `coach` is the surface being narrated, not
* a data domain. `labs` / `achievements` / `insights` / `doctorReport`
* own no coach-snapshot data domain.
*/
@@ -1037,15 +1038,16 @@ async function buildCoachSnapshotImpl(
now,
coachLocale,
);
// v1.15 — cycle/phase block, gated on the resolved cycle toggle so a
// v1.15 — cycle/phase block, gated on the resolved cycle module so a
// non-cycle account issues no query (the helper short-circuits to null
// before any read for a disabled account). The block is descriptive only —
// current phase + day-of-cycle, the next predicted event (period range,
// fertile window goal-gated), and the headline phase-correlation finding.
const cycleEnabled = isCycleEnabled(
prefsRow?.gender,
prefsRow?.cycleProfile ?? null,
);
// v1.18.0 — the gate is the FULLY-resolved cycle module
// (`isCycleAvailableForUser` → the per-user toggle AND the operator
// server-wide kill-switch), so an operator-off instance never injects the
// cycle block into the coach prompt.
const cycleEnabled = await isCycleAvailableForUser(userId);
const cycleBlockPromise = cycleEnabled
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
+77 -2
View File
@@ -1,9 +1,28 @@
/**
* v1.15.0 — cycle-tracking feature gate resolver.
* v1.18.0 — the route guard + the server-side availability helper now AND
* in the operator server-wide kill-switch via the module foundation.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate";
const isModuleEnabledMock = vi.fn<(userId: string, key: string) => Promise<boolean>>();
const getOrCreateCycleProfileMock = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: (userId: string, key: string) =>
isModuleEnabledMock(userId, key),
}));
vi.mock("@/lib/cycle/profile", () => ({
getOrCreateCycleProfile: (...args: unknown[]) =>
getOrCreateCycleProfileMock(...args),
}));
import {
isCycleEnabled,
isCycleAvailableForUser,
requireCycleEnabled,
CYCLE_DISABLED_ERROR_CODE,
} from "../gate";
describe("isCycleEnabled", () => {
it("derives from gender when the toggle is null", () => {
@@ -39,3 +58,59 @@ describe("isCycleEnabled", () => {
expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled");
});
});
describe("isCycleAvailableForUser (operator AND user — v1.18.0)", () => {
beforeEach(() => {
isModuleEnabledMock.mockReset();
getOrCreateCycleProfileMock.mockReset();
});
it("delegates to the cycle ModuleKey so the operator kill-switch is AND-ed in", async () => {
isModuleEnabledMock.mockResolvedValue(false);
await expect(isCycleAvailableForUser("u1")).resolves.toBe(false);
expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle");
});
it("reports available when the module gate resolves true", async () => {
isModuleEnabledMock.mockResolvedValue(true);
await expect(isCycleAvailableForUser("u1")).resolves.toBe(true);
});
});
describe("requireCycleEnabled (route guard — v1.18.0)", () => {
beforeEach(() => {
isModuleEnabledMock.mockReset();
getOrCreateCycleProfileMock.mockReset();
getOrCreateCycleProfileMock.mockResolvedValue({
id: "cp-1",
cycleTrackingEnabled: true,
});
});
it("passes through the profile when the resolved module is enabled", async () => {
isModuleEnabledMock.mockResolvedValue(true);
const result = await requireCycleEnabled("u1", "FEMALE");
expect(result.enabled).toBe(true);
if (result.enabled) {
expect(result.profile).toMatchObject({ id: "cp-1" });
}
expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle");
});
it("returns a 403 cycle.disabled envelope when the operator turned the module off", async () => {
// Per-user toggle is on (profile.cycleTrackingEnabled = true) — the only
// thing closing the gate is the operator server-wide kill-switch routed
// through the module foundation.
isModuleEnabledMock.mockResolvedValue(false);
const result = await requireCycleEnabled("u1", "FEMALE");
expect(result.enabled).toBe(false);
if (!result.enabled) {
expect(result.response.status).toBe(403);
const body = (await result.response.json()) as {
error: string | null;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe(CYCLE_DISABLED_ERROR_CODE);
}
});
});
+44 -9
View File
@@ -5,12 +5,22 @@
* (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and
* (b) a gender-derived default when that toggle is NULL.
*
* `isCycleEnabled` is the single source of truth. The server gender enum
* ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on
* `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
* `isCycleEnabled` is the per-user (LAYER 2) source of truth. The server
* gender enum ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys
* on `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
* `true`/`false` overrides the derivation (so a non-FEMALE account can
* opt in and a FEMALE account can opt out).
*
* v1.18.0 — `isCycleEnabled` is the USER layer only and stays pure: the
* module foundation's `resolveModuleEnabled` AND-s it with the operator
* server-wide kill-switch (LAYER 1). Every SERVER-SIDE consumer that wants
* the fully-resolved decision (routes, the cycle-reminder cron, the coach
* cycle block, the import accumulator) must go through
* `isCycleAvailableForUser` / `requireCycleEnabled`, which delegate to
* `isModuleEnabled(userId, "cycle")` so an operator-off instance suppresses
* the whole vertical automatically. Clients read `user.modules.cycle`
* (already AND-ed) rather than the operator-unaware `cycleTrackingEnabled`.
*
* NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a
* separate, iOS-blocked decision (migration 0131, reserved). Until that
* lands, `OTHER` / non-binary accounts gate purely on the explicit
@@ -18,11 +28,29 @@
*/
import { apiError } from "@/lib/api-response";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { CycleProfile } from "@/generated/prisma/client";
/** Wire `errorCode` the iOS retry classifier branches on. */
export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled";
/**
* Fully-resolved cycle availability for an account: the per-user toggle
* (`isCycleEnabled`) AND-ed with the operator server-wide kill-switch
* (`AppSettings.moduleAvailabilityJson.cycle`). This delegates to the
* module foundation's `isModuleEnabled(userId, "cycle")`, which routes the
* `cycle` ModuleKey back through `isCycleEnabled` for the user layer — so
* there is exactly one source of truth and no duplicated operator logic.
*
* Every SERVER-SIDE cycle consumer (the cron, the coach cycle block, the
* import accumulator) should call this rather than the operator-unaware
* `isCycleEnabled`, so an operator-disabled instance suppresses the whole
* vertical automatically.
*/
export function isCycleAvailableForUser(userId: string): Promise<boolean> {
return isModuleEnabled(userId, "cycle");
}
/**
* Resolve whether cycle tracking is enabled for an account.
*
@@ -51,16 +79,23 @@ export type CycleGateResult =
/**
* Enforce the gate for a `/api/cycle/*` route. Lazily upserts the
* `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`,
* and returns a 403 `cycle.disabled` envelope when the account may not
* write/read cycle data — even with a valid Bearer token.
* `CycleProfile` (so the toggle row exists), resolves the FULLY two-layer
* decision — the per-user toggle (`isCycleEnabled`) AND the operator
* server-wide kill-switch — and returns a 403 `cycle.disabled` envelope
* when the account may not write/read cycle data, even with a valid Bearer
* token. The `gender` argument stays in the signature for call-site
* symmetry; the resolved decision reads gender through the module gate.
*/
export async function requireCycleEnabled(
userId: string,
gender: string | null | undefined,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_gender: string | null | undefined,
): Promise<CycleGateResult> {
const profile = await getOrCreateCycleProfile(userId);
if (!isCycleEnabled(gender, profile)) {
const [profile, available] = await Promise.all([
getOrCreateCycleProfile(userId),
isCycleAvailableForUser(userId),
]);
if (!available) {
return {
enabled: false,
response: apiError("Cycle tracking is not enabled", 403, {
+10 -16
View File
@@ -21,7 +21,7 @@
*/
import { prisma } from "@/lib/db";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { auditLog } from "@/lib/auth/audit";
import {
mapHkCycleSample,
@@ -154,25 +154,19 @@ export class CycleImportAccumulator {
}
/**
* Resolve whether cycle tracking is enabled for the user (gender-derived
* or the explicit toggle). Cached for the parse lifetime. The importer
* Resolve whether the cycle module is enabled for the user — the
* fully-resolved two-layer gate (per-user toggle AND the operator
* server-wide kill-switch). Cached for the parse lifetime. The importer
* skips the whole cycle fold when this is false so a non-cycle account's
* Apple Health export never silently provisions cycle rows.
* (or an operator-disabled instance's) Apple Health export never silently
* provisions cycle rows.
*/
async isEnabled(): Promise<boolean> {
if (this.gated !== null) return this.gated;
const [user, profile] = await Promise.all([
prisma.user.findUnique({
where: { id: this.userId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: this.userId },
select: { cycleTrackingEnabled: true },
}),
]);
// The single source-of-truth gate (no profile auto-create on import).
this.gated = isCycleEnabled(user?.gender, profile);
// v1.18.0 — the FULLY-resolved cycle module: the per-user toggle AND the
// operator server-wide kill-switch. An operator-disabled instance never
// accumulates / writes cycle rows from an import. No profile auto-create.
this.gated = await isCycleAvailableForUser(this.userId);
return this.gated;
}
+48 -2
View File
@@ -33,6 +33,7 @@ import {
PERIOD_CONFIRM_GRACE_DAYS,
FERTILE_SOON_LEAD_DAYS,
} from "../cycle-reminder";
import { isCycleEnabled } from "@/lib/cycle/gate";
import type { NotificationPayload } from "@/lib/notifications/types";
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
@@ -297,13 +298,38 @@ describe("buildCycleReminderPayload", () => {
});
describe("runCycleReminderTick", () => {
function run(state: FakeState, dispatch: DispatchFn, now = AT_0900_BERLIN) {
function run(
state: FakeState,
dispatch: DispatchFn,
now = AT_0900_BERLIN,
overrides: {
/**
* v1.18.0 — inject the module gate. Default resolves the per-user
* cycle toggle off the FakeState user (the same `isCycleEnabled`
* derivation the cron used to apply inline), so the gender-derivation
* cases stay faithful. Pass `operatorOff: true` to assert the operator
* server-wide kill-switch suppresses every push regardless of the
* per-user toggle.
*/
operatorOff?: boolean;
} = {},
) {
const prisma = makePrisma(state);
const isModuleEnabled = vi.fn(async (userId: string) => {
if (overrides.operatorOff) return false;
const pred = state.predictions.find((p) => p.userId === userId);
const u = pred?.user;
return isCycleEnabled(u?.gender ?? null, u?.cycleProfile ?? null);
});
return runCycleReminderTick(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
now,
{ dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification },
{
dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification,
isModuleEnabled:
isModuleEnabled as unknown as typeof import("@/lib/modules/gate").isModuleEnabled,
},
);
}
@@ -447,6 +473,26 @@ describe("runCycleReminderTick", () => {
const summary = await run(state, dispatch);
expect(dispatch).not.toHaveBeenCalled();
expect(summary.dispatchedPeriodSoon).toBe(0);
expect(summary.skippedModuleDisabled).toBe(1);
});
it("operator server-wide kill-switch suppresses every cycle push (v1.18.0)", async () => {
// A fully cycle-enabled FEMALE account with an in-window prediction:
// the only thing stopping the push is the operator-level module gate.
const state: FakeState = {
predictions: [
{ userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() },
],
observedStarts: {},
alreadyOk: new Set(),
};
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await run(state, dispatch, AT_0900_BERLIN, {
operatorOff: true,
});
expect(dispatch).not.toHaveBeenCalled();
expect(summary.dispatchedPeriodSoon).toBe(0);
expect(summary.skippedModuleDisabled).toBe(1);
});
it("opts a non-FEMALE account in via the explicit cycleTrackingEnabled flag", async () => {
+24 -9
View File
@@ -43,7 +43,7 @@ import { defaultLocale, locales } from "@/lib/i18n/config";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dayDiff } from "@/lib/cycle/day-math";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isModuleEnabled } from "@/lib/modules/gate";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { EventType } from "@/lib/notifications/types";
import { isCycleReminderClientManaged } from "@/lib/validations/notification-prefs";
@@ -92,6 +92,7 @@ export interface CycleReminderSummary {
skippedNoChannel: number;
skippedOutsideWindow: number;
skippedPredictionDisabled: number;
skippedModuleDisabled: number;
skippedAlreadyLogged: number;
failed: number;
}
@@ -129,6 +130,7 @@ function emptySummary(): CycleReminderSummary {
skippedNoChannel: 0,
skippedOutsideWindow: 0,
skippedPredictionDisabled: 0,
skippedModuleDisabled: 0,
skippedAlreadyLogged: 0,
failed: 0,
};
@@ -290,9 +292,18 @@ export async function runCycleReminderTick(
now: Date,
options: {
dispatch?: typeof dispatchNotification;
/**
* v1.18.0 module gate — injectable so the unit tests pin the
* operator/user disabled-module skip without booting the gate's DB
* reads. Defaults to the real `isModuleEnabled` resolver. The `cycle`
* ModuleKey resolves the per-user toggle AND the operator server-wide
* kill-switch, mirroring the mood / measurement reminder crons.
*/
isModuleEnabled?: typeof isModuleEnabled;
} = {},
): Promise<CycleReminderSummary> {
const dispatchImpl = options.dispatch ?? dispatchNotification;
const moduleGate = options.isModuleEnabled ?? isModuleEnabled;
const summary = emptySummary();
// Push the gating into the query so the per-tick cost scales with the
@@ -362,14 +373,18 @@ export async function runCycleReminderTick(
try {
const profile = user.cycleProfile;
// Gate ENTIRELY on cycle tracking being enabled (gender-derived or
// explicit opt-in) — a disabled account never receives a cycle push.
// v1.18.0 — this IS the cycle module gate: the `cycle` ModuleKey
// delegates to `isCycleEnabled`, so turning the Cycle module off
// here is the same single source of truth the gate reads. No extra
// `isModuleEnabled(userId, "cycle")` round-trip is needed.
if (!isCycleEnabled(user.gender, profile)) {
summary.skippedOutsideWindow += 1;
// Gate on the FULLY-resolved cycle module — the per-user toggle
// (gender-derived or explicit opt-in) AND the operator server-wide
// kill-switch. v1.18.0: the `cycle` ModuleKey resolves through
// `isModuleEnabled`, so an operator who disables the Cycle module
// (`AppSettings.moduleAvailabilityJson.cycle = false`) suppresses
// every CYCLE_PERIOD push for every account — the same two-layer gate
// the routes and the coach cycle block read. A per-user-disabled
// account is likewise skipped. The `profile` row read below still
// applies the `predictionEnabled` opt-out.
if (!(await moduleGate(user.id, "cycle"))) {
summary.skippedModuleDisabled += 1;
getEvent()?.addMeta("cycle_reminder.skipped_module_disabled", user.id);
continue;
}