From b02ebb7cf652f6b54dfc2f642b4c3f56dd808872 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 12:17:45 +0200 Subject: [PATCH] fix(intake-public): read request headers via next/headers, not param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8a Task 7 shipped the headers argument as part of the Server Action signature. Server Actions serialise their arguments through Next's RSC protocol, and a Headers instance does not survive that — it ends up as something with no .get() and the action explodes with 'headers.get is not a function' the first time the wizard runs. Fix: drop the headers parameter from the action and read the inbound request's headers via next/headers inside the action. The client component no longer needs to construct a Headers instance either. This was caught the first time a real browser hit the wizard — the integration tests in Task 17 mock the request and never serialised a Headers through RSC. --- src/app/actions/intake-public.ts | 13 ++++++++++--- src/components/intake/intake-public.tsx | 5 ----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/actions/intake-public.ts b/src/app/actions/intake-public.ts index d0baa1f..791d2b3 100644 --- a/src/app/actions/intake-public.ts +++ b/src/app/actions/intake-public.ts @@ -1,5 +1,7 @@ "use server"; +import { headers } from "next/headers"; + import { clientIpFromHeaders } from "@/lib/http/client-ip"; import { allow } from "@/lib/intake-links/rate-limit"; import { validateIntakeLinkForStep } from "@/lib/intake-links"; @@ -14,6 +16,12 @@ import type { Turn } from "@/lib/intake/turn"; * shape check happen here, in that order, before any token lookup touches * the database. A bad-shape request never charges a rate-limit bucket. * + * The client IP is read via `next/headers` rather than passed in. Server + * Actions serialise their arguments through Next's RSC protocol, and + * `Headers` instances do not survive that — passing one as a parameter + * ended up as something with no `.get()`. `headers()` is the supported way + * for a Server Action to read the inbound request's headers. + * * Returns the same shape as `intakeStepAction` so the wizard code does not * branch on which side it is on. The "no longer active" wording is the same * as "daily cap reached" intentionally — the spec's §3 "doesn't shout" @@ -24,14 +32,13 @@ export async function intakeStepPublicAction(input: { token: string; turns: Turn[]; finishNow: boolean; - /** Caller passes its own request headers so the limiter can key on IP. */ - headers: Headers; }): Promise { if (!isIntakeToken(input.token)) { return { kind: "unavailable", message: "This link is no longer active." }; } - const ip = clientIpFromHeaders(input.headers); + const requestHeaders = await headers(); + const ip = clientIpFromHeaders(requestHeaders); if (!allow(`${input.token}:${ip}`)) { return { kind: "unavailable", message: "Too many requests right now." }; } diff --git a/src/components/intake/intake-public.tsx b/src/components/intake/intake-public.tsx index d4bad4c..b02b78f 100644 --- a/src/components/intake/intake-public.tsx +++ b/src/components/intake/intake-public.tsx @@ -37,11 +37,6 @@ export function IntakePublic({ token, turns: nextTurns, finishNow, - // The Server Action reads Headers() inside Next; we have no Headers - // here, but the limiter uses an opaque key and the missing client IP - // collapses all callers from this browser into one bucket. That's - // fine — it is the worst case, not the typical one. - headers: new Headers(), }); }