From 2f44654dbe8123ce0ea6cc98d2d16881545df21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 17:06:51 +0200 Subject: [PATCH] fix(cycle): keep custom-symptom client constants out of the server crypto module The log sheet's chip grid imported the icon allow-list from custom-symptoms.ts, which pulls node:crypto + the AES helpers into the client bundle and broke the Turbopack build for /cycle. Split the client-safe constants and Zod schemas into custom-symptoms-shared.ts; the server module re-exports them so the API routes are unchanged, and the client imports from the shared module. --- src/components/cycle/log-day-sheet.tsx | 2 +- src/lib/cycle/custom-symptoms-shared.ts | 89 +++++++++++++++++++++++ src/lib/cycle/custom-symptoms.ts | 95 +++++-------------------- 3 files changed, 106 insertions(+), 80 deletions(-) create mode 100644 src/lib/cycle/custom-symptoms-shared.ts diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 036813566..10e0336c5 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -43,7 +43,7 @@ import { } from "@/components/ui/popover"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; -import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms"; +import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import { FLOW_HUE } from "./phase-tokens"; import { diff --git a/src/lib/cycle/custom-symptoms-shared.ts b/src/lib/cycle/custom-symptoms-shared.ts new file mode 100644 index 000000000..6ca41bd80 --- /dev/null +++ b/src/lib/cycle/custom-symptoms-shared.ts @@ -0,0 +1,89 @@ +import { z } from "zod/v4"; + +/** + * v1.15.1 — client-safe constants + Zod schemas for custom cycle symptoms. + * + * Split out of `custom-symptoms.ts` so a `"use client"` component (the log + * sheet's chip grid) can read the icon allow-list and key helpers WITHOUT + * pulling the server-only crypto / `node:crypto` the mint + label-encryption + * helpers depend on into the browser bundle. The server module re-exports + * everything here, so existing server import sites are unaffected. + */ + +/** Reserved key prefix for a user-minted custom symptom. */ +export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:"; + +/** Stable category key + seeded id every custom symptom hangs under. */ +export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom"; +export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom"; + +/** Per-user ceiling on custom symptoms (422 over the cap). */ +export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50; + +/** + * Allow-list of Lucide icon names a custom symptom may use. Kept to names the + * iOS client maps to an SF Symbol so a custom symptom never falls back to the + * generic glyph on one platform. An unknown name → 422. `Tag` is the default. + */ +export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [ + "Tag", + "Activity", + "Heart", + "HeartPulse", + "Brain", + "Zap", + "Flame", + "Snowflake", + "Droplet", + "CircleDot", + "BatteryLow", + "MoonStar", + "PersonStanding", + "Drama", + "Frown", + "Cookie", + "Soup", + "Pill", + "Thermometer", + "Stethoscope", +] as const; + +const ICON_SET = new Set(CUSTOM_SYMPTOM_ICON_ALLOWLIST); + +/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */ +export function isCustomSymptomKey(key: string): boolean { + return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX); +} + +const labelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +const iconSchema = z + .string() + .refine((v) => ICON_SET.has(v), "Unknown icon") + .nullish(); + +/** POST body — create a custom symptom. */ +export const createCustomSymptomSchema = z.object({ + label: labelSchema, + icon: iconSchema, + // Reserved for a future per-symptom category choice; v1 pins everything to + // the `custom` category, so a supplied value other than `custom` is rejected. + categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(), +}); + +/** PATCH body — update a custom symptom (every field optional). */ +export const updateCustomSymptomSchema = z + .object({ + label: labelSchema.optional(), + icon: iconSchema, + isActive: z.boolean().optional(), + }) + .refine( + (v) => + v.label !== undefined || v.icon !== undefined || v.isActive !== undefined, + "At least one field is required", + ); diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts index 3028c29eb..d4fa21123 100644 --- a/src/lib/cycle/custom-symptoms.ts +++ b/src/lib/cycle/custom-symptoms.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; -import { z } from "zod/v4"; - import { encrypt, decrypt } from "@/lib/crypto"; +import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared"; + /** * v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag * precedent, mirrored 1:1). @@ -17,52 +17,22 @@ import { encrypt, decrypt } from "@/lib/crypto"; * is held AES-256-GCM-encrypted at rest (`labelEncrypted`): a symptom name is * intent-revealing free text, the highest-sensitivity category in the * post-Dobbs threat model. + * + * This module carries the SERVER-ONLY helpers (uuid mint + label crypto). The + * client-safe constants + Zod schemas live in `custom-symptoms-shared.ts` and + * are re-exported here so existing server import sites need no change. */ -/** Reserved key prefix for a user-minted custom symptom. */ -export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:"; - -/** Stable category key + seeded id every custom symptom hangs under. */ -export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom"; -export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom"; - -/** Per-user ceiling on custom symptoms (422 over the cap). */ -export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50; - -/** - * Allow-list of Lucide icon names a custom symptom may use. Kept to names the - * iOS client maps to an SF Symbol so a custom symptom never falls back to the - * generic glyph on one platform. An unknown name → 422. `Tag` is the default. - */ -export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [ - "Tag", - "Activity", - "Heart", - "HeartPulse", - "Brain", - "Zap", - "Flame", - "Snowflake", - "Droplet", - "CircleDot", - "BatteryLow", - "MoonStar", - "PersonStanding", - "Drama", - "Frown", - "Cookie", - "Soup", - "Pill", - "Thermometer", - "Stethoscope", -] as const; - -const ICON_SET = new Set(CUSTOM_SYMPTOM_ICON_ALLOWLIST); - -/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */ -export function isCustomSymptomKey(key: string): boolean { - return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX); -} +export { + CUSTOM_SYMPTOM_KEY_PREFIX, + CUSTOM_SYMPTOM_CATEGORY_KEY, + CUSTOM_SYMPTOM_CATEGORY_ID, + MAX_CUSTOM_SYMPTOMS_PER_USER, + CUSTOM_SYMPTOM_ICON_ALLOWLIST, + isCustomSymptomKey, + createCustomSymptomSchema, + updateCustomSymptomSchema, +} from "@/lib/cycle/custom-symptoms-shared"; /** Mint a fresh, collision-proof custom-symptom key. */ export function mintCustomSymptomKey(): string { @@ -86,36 +56,3 @@ export function decryptCustomLabel(encoded: string | null): string | null { return null; } } - -const labelSchema = z - .string() - .trim() - .min(1, "Label must not be empty") - .max(40, "Label must be at most 40 characters"); - -const iconSchema = z - .string() - .refine((v) => ICON_SET.has(v), "Unknown icon") - .nullish(); - -/** POST body — create a custom symptom. */ -export const createCustomSymptomSchema = z.object({ - label: labelSchema, - icon: iconSchema, - // Reserved for a future per-symptom category choice; v1 pins everything to - // the `custom` category, so a supplied value other than `custom` is rejected. - categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(), -}); - -/** PATCH body — update a custom symptom (every field optional). */ -export const updateCustomSymptomSchema = z - .object({ - label: labelSchema.optional(), - icon: iconSchema, - isActive: z.boolean().optional(), - }) - .refine( - (v) => - v.label !== undefined || v.icon !== undefined || v.isActive !== undefined, - "At least one field is required", - );