fix(api-handler): narrow safeRequestProp catch + harden @source path resolution

Two carry-overs from the W7d dev-server repair, flagged for v1.4.26 in
the post-W7d follow-up list.

1. `safeRequestProp` previously caught every exception from the read
   callback so logging instrumentation never crashed the wrapped
   handler. That was overly broad — a regression in a header parser
   or a `null` access inside `read` would silently fall back to the
   default and hide the bug. The catch is now narrowed to the V8
   private-field `TypeError` that Next 16 raises when the request is
   a synthetic placeholder on force-static handlers; every other
   error rethrows. Three message-shape variants are recognised
   (`private member`, `private field`, `private name`) to cover
   current V8 + Bun + older runtimes.

2. `src/app/globals.css` now imports Tailwind with an explicit
   `source("../..")` base. Tailwind v4 auto-detection otherwise walks
   up from the CSS file's resolver root, which can land at an
   unexpected level depending on how the build tool reaches the
   stylesheet. Pinning the base 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 where the build is invoked
   from.

The narrow-catch helper is exposed through a `__testables` export so
its contract can be unit-tested directly. The new test file covers:
  - the three private-field message variants classify as match
  - generic TypeErrors, RangeErrors, and bare Errors classify as
    non-match (must rethrow)
  - safeRequestProp returns the read result on a real request
  - safeRequestProp returns the fallback on a private-field TypeError
  - safeRequestProp rethrows any other exception

Verification:
  pnpm typecheck                           → clean
  pnpm lint                                → clean
  pnpm vitest run api-handler-safe-request-prop → 9 / 9 pass
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:45:56 +02:00
1 parent 7b766bedab
commit 61d156e71b
3 files changed
+181 -3

No files matched your search

+14 -1
View File
@@ -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 `<repo-root>/.planning` and
* `<repo-root>/docs`.
*/
@source not "../../.planning";
@source not "../../docs";
@@ -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<string | null>(
{},
() => {
throw new TypeError("Cannot read private member #headers from object");
},
null,
);
expect(result).toBeNull();
});
});
+25 -2
View File
@@ -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<R>(
request: unknown,
read: (req: NextRequest) => R,
@@ -47,11 +63,18 @@ function safeRequestProp<R>(
): 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[]`