diff --git a/src/__tests__/env-empty-is-unset-guard.test.ts b/src/__tests__/env-empty-is-unset-guard.test.ts index c2ed56f31..a50b87bbc 100644 --- a/src/__tests__/env-empty-is-unset-guard.test.ts +++ b/src/__tests__/env-empty-is-unset-guard.test.ts @@ -38,16 +38,6 @@ const SRC = join(process.cwd(), "src"); const NULLISH_ENV_FALLBACK = /process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\s*["'`][A-Za-z_][A-Za-z0-9_]*["'`]\s*\])(?:\?\.[A-Za-z_]+\((?:[^()]|\([^()]*\))*\))*\s*\?\?/g; -/** - * Files that still carry the pattern because another change owns them. - * Each entry fails T3 once the file no longer matches, so the list can only - * shrink. - */ -const PENDING: Readonly> = { - "lib/withings/client.ts": - "WITHINGS_REDIRECT_URI ?? derived callback; the integration clients are being reworked in a parallel change that owns this file", -}; - function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) .filter((rel) => !rel.startsWith("generated/")) @@ -88,20 +78,30 @@ describe("an empty environment variable counts as unset", () => { }); it("T2 — no source file defaults a process.env read with ??", () => { - const found = offenders().filter((rel) => !(rel in PENDING)); expect( - found, + offenders(), "Read these through envValue/envOr/envFlag from @/lib/env. Compose " + "passes an unset variable as the empty string, and ?? keeps it.", ).toEqual([]); }); - it("T3 — every pending entry still matches, so the list only shrinks", () => { - const current = new Set(offenders()); - const stale = Object.keys(PENDING).filter((rel) => !current.has(rel)); - expect( - stale, - "No longer carries the pattern; drop the entry from PENDING.", - ).toEqual([]); + it("T3 — the sweep read the tree and the matcher fires on real source", () => { + // An empty offender list proves nothing if the narrowed walk found no + // files, or if the matcher no longer fires on what the tree actually + // holds. Floor the narrowed set, then put the pre-fix read back into a + // real client and require the matcher to find it there. + const files = sourceFiles(); + expect(files.length).toBeGreaterThan(2400); + expect(files).toContain("lib/withings/client.ts"); + const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8"); + expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]); + const reverted = real.replace( + /envOr\(\s*"WITHINGS_REDIRECT_URI",/, + "process.env.WITHINGS_REDIRECT_URI ?? (", + ); + expect(reverted).not.toBe(real); + expect([...reverted.matchAll(NULLISH_ENV_FALLBACK)].length).toBeGreaterThan( + 0, + ); }); }); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 6a01c454f..3d39eea8c 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -42,6 +42,7 @@ import { safeFetch } from "@/lib/safe-fetch"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; +import { envValue } from "@/lib/env"; /** Classic Fitbit Web API base. */ export const FITBIT_API_BASE = "https://api.fitbit.com"; @@ -80,8 +81,8 @@ export function getFitbitRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must fall through to // the derived URI rather than read as "not configured". - const explicit = process.env.FITBIT_REDIRECT_URI?.trim() || undefined; - const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; + const explicit = envValue("FITBIT_REDIRECT_URI"); + const appUrl = envValue("NEXT_PUBLIC_APP_URL"); const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 6a675631b..0a21e75fc 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -48,6 +48,7 @@ import type { GoogleHealthDataType, GoogleHealthRollupPoint, } from "./mappers"; +import { envValue } from "@/lib/env"; export * from "./mappers"; @@ -150,8 +151,8 @@ export function getGoogleHealthRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must fall through to // the derived URI rather than read as "not configured". - const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI?.trim() || undefined; - const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; + const explicit = envValue("GOOGLE_HEALTH_REDIRECT_URI"); + const appUrl = envValue("NEXT_PUBLIC_APP_URL"); const raw = explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 577087167..0460c315f 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -18,6 +18,7 @@ import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { dayDiff } from "@/lib/cycle/day-math"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; +import { envOr, envValue } from "@/lib/env"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; @@ -44,9 +45,9 @@ export function getOuraCredentials(): OuraCredentials | null { export function getOuraRedirectUri(): string { // An empty or blank value counts as unset (the compose whitelist // materialises an unset var as an empty string). - return ( - process.env.OURA_REDIRECT_URI?.trim() || - `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/oura/callback` + return envOr( + "OURA_REDIRECT_URI", + `${envValue("NEXT_PUBLIC_APP_URL")}/api/oura/callback`, ); } diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index b3560d73f..a0aabfcc3 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -31,6 +31,7 @@ import type { Prisma } from "@/generated/prisma/client"; import type { WorkoutSportType } from "@/lib/validations/workout"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; import { mapPolarSportType } from "./sport-map"; +import { envOr, envValue } from "@/lib/env"; const POLAR_API_BASE = "https://www.polaraccesslink.com"; const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization"; @@ -57,9 +58,9 @@ export function getPolarRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must still fall // through to the derived URI. - return ( - process.env.POLAR_REDIRECT_URI?.trim() || - `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/polar/callback` + return envOr( + "POLAR_REDIRECT_URI", + `${envValue("NEXT_PUBLIC_APP_URL")}/api/polar/callback`, ); } diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index b3e68db25..4428f14e1 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -27,6 +27,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { StravaApiError, classifyStravaResponse } from "./response-classifier"; import { mapStravaSportType } from "./sport-map"; import type { WorkoutSportType } from "@/lib/validations/workout"; +import { envOr, envValue } from "@/lib/env"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize"; @@ -55,9 +56,9 @@ export function getStravaRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must still fall // through to the derived URI. - return ( - process.env.STRAVA_REDIRECT_URI?.trim() || - `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/strava/callback` + return envOr( + "STRAVA_REDIRECT_URI", + `${envValue("NEXT_PUBLIC_APP_URL")}/api/strava/callback`, ); } diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index bc91beb31..8f29c1e7d 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -19,6 +19,7 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { WhoopApiError, classifyWhoopResponse } from "./response-classifier"; +import { envOr, envValue } from "@/lib/env"; const WHOOP_API_BASE = "https://api.prod.whoop.com/developer"; const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; @@ -36,9 +37,9 @@ export function getWhoopRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises the var as an empty string when unset, which must still fall // through to the derived URI. - return ( - process.env.WHOOP_REDIRECT_URI?.trim() || - `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/whoop/callback` + return envOr( + "WHOOP_REDIRECT_URI", + `${envValue("NEXT_PUBLIC_APP_URL")}/api/whoop/callback`, ); } diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index 6bfe27f4e..e14cb0f23 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -8,6 +8,7 @@ import { WithingsApiError, classifyWithingsResponse, } from "./response-classifier"; +import { envOr, envValue } from "@/lib/env"; const WITHINGS_OAUTH_URL = "https://account.withings.com/oauth2_user/authorize2"; @@ -24,9 +25,9 @@ export function getWithingsRedirectUri(): string { // An empty or blank value counts as unset: the compose whitelist // materialises an unset var as an empty string, which must fall through to // the derived URI rather than go out as `redirect_uri=`. - return ( - process.env.WITHINGS_REDIRECT_URI?.trim() || - `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/withings/callback` + return envOr( + "WITHINGS_REDIRECT_URI", + `${envValue("NEXT_PUBLIC_APP_URL")}/api/withings/callback`, ); }