diff --git a/src/app/globals.css b/src/app/globals.css index 4dd6cac59..ea3744a81 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import "tailwindcss" source("../.."); @import "tw-animate-css"; @import "shadcn/tailwind.css"; @@ -13,6 +13,19 @@ * Turbopack's CSS parser rejects with `Unexpected token Delim('.')`, * 500-ing every page. Constraining the scan to source code avoids * fixture-style strings poisoning the generated CSS. + * + * The `source("../..")` argument on the `@import "tailwindcss"` line + * above pins auto-detection to the project root (resolved from this + * stylesheet) instead of letting Tailwind walk up from whatever cwd + * the build tool happens to be sitting at. That mirrors the + * cwd-anchored fix the safety-contracts loader landed in + * commit `dd44a0d` and stops the planning / docs directories from + * leaking class-shaped fragments into the generated CSS regardless of + * whether the build runs from the repo root or a tooling subdir. + * + * The `@source not` directives below are resolved relative to this + * stylesheet, so they point at `/.planning` and + * `/docs`. */ @source not "../../.planning"; @source not "../../docs"; diff --git a/src/lib/__tests__/api-handler-safe-request-prop.test.ts b/src/lib/__tests__/api-handler-safe-request-prop.test.ts new file mode 100644 index 000000000..defbf99c8 --- /dev/null +++ b/src/lib/__tests__/api-handler-safe-request-prop.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; + +// --- Mocks must be hoisted before importing the module under test. --- + +vi.mock("@/lib/db", () => ({ + prisma: {}, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { __testables } from "@/lib/api-handler"; + +const { safeRequestProp, isPrivateFieldAccessError } = __testables; + +describe("isPrivateFieldAccessError — narrow-catch classifier", () => { + it("returns true for the V8 private-field TypeError", () => { + const err = new TypeError( + "Cannot read private member #state from an object whose class did not declare it", + ); + expect(isPrivateFieldAccessError(err)).toBe(true); + }); + + it("returns true for the alternative 'private field' wording", () => { + const err = new TypeError( + "Cannot read private field #foo from object that was not created from its class", + ); + expect(isPrivateFieldAccessError(err)).toBe(true); + }); + + it("returns true for the Bun / older-V8 'private name' wording", () => { + const err = new TypeError("Cannot read private name #bar from object"); + expect(isPrivateFieldAccessError(err)).toBe(true); + }); + + it("returns false for any other TypeError (real bug, must surface)", () => { + expect( + isPrivateFieldAccessError(new TypeError("undefined is not a function")), + ).toBe(false); + expect( + isPrivateFieldAccessError(new TypeError("Cannot read properties of null")), + ).toBe(false); + }); + + it("returns false for non-TypeError exceptions", () => { + expect(isPrivateFieldAccessError(new Error("private member #x"))).toBe( + false, + ); + expect(isPrivateFieldAccessError(new RangeError("private member"))).toBe( + false, + ); + expect(isPrivateFieldAccessError("private member string")).toBe(false); + expect(isPrivateFieldAccessError(null)).toBe(false); + expect(isPrivateFieldAccessError(undefined)).toBe(false); + }); +}); + +describe("safeRequestProp — narrow-catch behaviour", () => { + it("returns the read result when the request is a real NextRequest-shape", () => { + const req = { method: "POST" }; + const result = safeRequestProp(req, (r) => r.method, "GET"); + expect(result).toBe("POST"); + }); + + it("returns the fallback when the read raises the private-field TypeError", () => { + const result = safeRequestProp( + {}, + () => { + throw new TypeError( + "Cannot read private member #state from an object whose class did not declare it", + ); + }, + "FALLBACK", + ); + expect(result).toBe("FALLBACK"); + }); + + it("re-throws any other error so real bugs surface", () => { + expect(() => + safeRequestProp( + {}, + () => { + throw new Error("downstream parser exploded"); + }, + "FALLBACK", + ), + ).toThrow("downstream parser exploded"); + + expect(() => + safeRequestProp( + {}, + () => { + throw new TypeError("undefined is not a function"); + }, + "FALLBACK", + ), + ).toThrow("undefined is not a function"); + + expect(() => + safeRequestProp( + {}, + () => { + throw new RangeError("out of range"); + }, + "FALLBACK", + ), + ).toThrow("out of range"); + }); + + it("supports null fallbacks (header lookups return string | null)", () => { + const result = safeRequestProp( + {}, + () => { + throw new TypeError("Cannot read private member #headers from object"); + }, + null, + ); + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4b9b39b7b..3683c3c61 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -40,6 +40,22 @@ export class HttpError extends Error { // to `force-static` route handlers during dev). We probe defensively // and fall back to safe defaults so logging instrumentation never // crashes the handler. +// +// The catch is narrowed to the V8 private-field TypeError. Any other +// exception is a real bug in the read callback or in a downstream +// header parser and must surface — swallowing it here would hide +// regressions in production instrumentation. +function isPrivateFieldAccessError(err: unknown): boolean { + if (!(err instanceof TypeError)) return false; + const msg = err.message ?? ""; + return ( + msg.includes("private member") || + msg.includes("private field") || + // Bun / older V8 wording variant + msg.includes("private name") + ); +} + function safeRequestProp( request: unknown, read: (req: NextRequest) => R, @@ -47,11 +63,18 @@ function safeRequestProp( ): R { try { return read(request as NextRequest); - } catch { - return fallback; + } catch (err) { + if (isPrivateFieldAccessError(err)) return fallback; + throw err; } } +/** @internal — exposed for unit tests of the narrow-catch contract. */ +export const __testables = { + safeRequestProp, + isPrivateFieldAccessError, +}; + // Next.js route handlers come in two shapes — `(request)` for static routes // and `(request, { params })` for dynamic ones. The variadic generic is the // only signature TS accepts that covers both at the call site. The `any[]`