@duffcloudservices/site-forms 0.5.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,17 @@ import DcsForm from '../DcsForm.vue'
4
4
  import { submitFormValues } from '../composables/useFormSubmission'
5
5
  import type { PortalFormDefinition } from '../types'
6
6
 
7
+ // The REAL managed-form success shape (PublicSiteFormSubmissionResponse,
8
+ // types.gen.ts:14845 / public_site_forms.go:509). C-310 requires a genuine
9
+ // receipt before any success state, so these doubles must answer with one —
10
+ // they previously returned invented shapes ({leadId}, {status:'ok'}) that the
11
+ // server cannot produce.
12
+ const MANAGED_FORM_RECEIPT = {
13
+ id: '07438812886145977836-dd5a5baf3835082c',
14
+ status: 'accepted',
15
+ submittedAt: '2026-07-26T14:52:30Z',
16
+ }
17
+
7
18
  const def: PortalFormDefinition = {
8
19
  formId: 'contact',
9
20
  submission: { kind: 'lead' },
@@ -19,7 +30,7 @@ describe('submission happy path', () => {
19
30
  const fetchMock = vi.fn().mockResolvedValue({
20
31
  ok: true,
21
32
  status: 200,
22
- json: async () => ({ leadId: 'L-1' }),
33
+ json: async () => MANAGED_FORM_RECEIPT,
23
34
  })
24
35
  const originalFetch = globalThis.fetch
25
36
  ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
@@ -42,8 +53,14 @@ describe('submission happy path', () => {
42
53
  await wrapper.find('form').trigger('submit')
43
54
  await flushPromises()
44
55
 
45
- expect(fetchMock).toHaveBeenCalledTimes(1)
46
- const [url, init] = fetchMock.mock.calls[0]
56
+ // Exactly one SUBMISSION. Filtered to POST because the mount also issues the
57
+ // C-415 form-compliance GET; asserting on total fetch calls would conflate
58
+ // "the form submitted twice" with "the form read its PHI posture".
59
+ const posts = fetchMock.mock.calls.filter(
60
+ ([, init]) => (init as RequestInit | undefined)?.method === 'POST',
61
+ )
62
+ expect(posts).toHaveLength(1)
63
+ const [url, init] = posts[0]
47
64
  expect(url).toBe('https://api.example.com/sites/kept/forms/contact/submissions')
48
65
  expect((init as RequestInit).method).toBe('POST')
49
66
  expect(((init as RequestInit).headers as Record<string, string>)['Content-Type']).toBe(
@@ -79,7 +96,7 @@ describe('submission happy path', () => {
79
96
  const fetchMock = vi.fn().mockResolvedValue({
80
97
  ok: true,
81
98
  status: 202,
82
- json: async () => ({ status: 'ok' }),
99
+ json: async () => MANAGED_FORM_RECEIPT,
83
100
  })
84
101
 
85
102
  await submitFormValues({
@@ -103,7 +120,7 @@ describe('submission happy path', () => {
103
120
  const fetchMock = vi.fn().mockResolvedValue({
104
121
  ok: true,
105
122
  status: 202,
106
- json: async () => ({ status: 'ok' }),
123
+ json: async () => MANAGED_FORM_RECEIPT,
107
124
  })
108
125
  const file = new File(['hello'], 'estimate.txt', { type: 'text/plain' })
109
126
 
@@ -141,7 +158,7 @@ describe('submission happy path', () => {
141
158
  const fetchMock = vi.fn().mockResolvedValue({
142
159
  ok: true,
143
160
  status: 202,
144
- json: async () => ({ status: 'ok' }),
161
+ json: async () => MANAGED_FORM_RECEIPT,
145
162
  })
146
163
  const photo1 = new File(['one'], 'one.jpg', { type: 'image/jpeg' })
147
164
  const photo2 = new File(['two'], 'two.png', { type: 'image/png' })
@@ -27,12 +27,21 @@ const def: PortalFormDefinition = {
27
27
  ],
28
28
  }
29
29
 
30
+ // C-310: a success state now requires the endpoint's real receipt
31
+ // (PublicSiteFormSubmissionResponse, types.gen.ts:14845), so these doubles must
32
+ // answer with one rather than an arbitrary object.
33
+ const MANAGED_FORM_RECEIPT = {
34
+ id: '07438812886145977836-dd5a5baf3835082c',
35
+ status: 'accepted',
36
+ submittedAt: '2026-07-26T14:52:30Z',
37
+ }
38
+
30
39
  describe('visibleIf', () => {
31
40
  it('skips validation and submission for hidden gated fields', async () => {
32
41
  const fetchMock = vi.fn().mockResolvedValue({
33
42
  ok: true,
34
43
  status: 200,
35
- json: async () => ({ id: '1' }),
44
+ json: async () => MANAGED_FORM_RECEIPT,
36
45
  })
37
46
  // Stub global fetch
38
47
  const originalFetch = globalThis.fetch
@@ -56,8 +65,12 @@ describe('visibleIf', () => {
56
65
  await wrapper.find('form').trigger('submit')
57
66
  await flushPromises()
58
67
 
59
- expect(fetchMock).toHaveBeenCalledTimes(1)
60
- const [, init] = fetchMock.mock.calls[0]
68
+ // POST-filtered: the mount also issues the C-415 form-compliance GET.
69
+ const posts = fetchMock.mock.calls.filter(
70
+ ([, init]) => (init as RequestInit | undefined)?.method === 'POST',
71
+ )
72
+ expect(posts).toHaveLength(1)
73
+ const [, init] = posts[0]
61
74
  const body = JSON.parse((init as RequestInit).body as string)
62
75
  expect(body.values).toEqual({ kind: 'a' })
63
76
  expect('detail' in body.values).toBe(false)
@@ -69,7 +82,7 @@ describe('visibleIf', () => {
69
82
  const fetchMock = vi.fn().mockResolvedValue({
70
83
  ok: true,
71
84
  status: 200,
72
- json: async () => ({}),
85
+ json: async () => MANAGED_FORM_RECEIPT,
73
86
  })
74
87
  const originalFetch = globalThis.fetch
75
88
  ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
@@ -0,0 +1,360 @@
1
+ /**
2
+ * CANONICAL receipt primitive for DCS form-submission clients (C-310).
3
+ *
4
+ * This module is the single definition of "did the submission actually get
5
+ * stored?" for the whole fleet. Site-local copies (lamphere's
6
+ * `theme/lib/readOkBody.ts`, iron-oak's `src/lib/submissionResponse.ts`, and the
7
+ * first-party `web/`+`portal/` helpers) are BACKPORTS of this file and say so in
8
+ * their headers. Change the contract here first.
9
+ *
10
+ * ── Two layers, deliberately separate ──────────────────────────────────────
11
+ *
12
+ * `readOkBody(response)` is TRANSPORT validation: did a DCS API answer at all?
13
+ * `readExpectedJson(response, validator)` adds RECEIPT validation: does the
14
+ * body it answered with actually prove a row exists?
15
+ *
16
+ * Transport validation alone is not enough (Codex cross-model review §c,
17
+ * 2026-07-26). Before C-310 any syntactically valid JSON passed — `{}`, `[]`,
18
+ * `"ok"`, `{"hello":"world"}` — so a 2xx from anything that speaks JSON was
19
+ * reported to the visitor as a stored lead. The endpoints all return a real
20
+ * receipt; requiring one is cheap and materially stronger.
21
+ *
22
+ * ── Why 204 is NOT exempt any more (C-310, was the last hole) ──────────────
23
+ *
24
+ * C-301/C-302 trusted an empty body when the STATUS asserted the emptiness
25
+ * (`204 No Content`). That was defensible in the abstract and wrong in fact:
26
+ * `204` proves only that *the responder* deliberately sent no content. It does
27
+ * not prove the DCS API ran, and every guarded endpoint returns a JSON receipt:
28
+ *
29
+ * - managed forms `202` + `PublicSiteFormSubmissionResponse`
30
+ * (server/internal/handlers/public_site_forms.go)
31
+ * - site contact `201` + `ContactFormResponse`
32
+ * (server/internal/handlers/contact_form.go)
33
+ * - estimate creation `201` + estimate JSON
34
+ * (server/internal/handlers/revenue_estimate_visitor.go)
35
+ * - passwordless verify `200` + `{success, redirectTo}`
36
+ * (server/internal/handlers/site_auth_multi.go:401)
37
+ * - booking cancellation `200` + `CancelBookingResponse`
38
+ * (server/internal/handlers/revenue_public.go:2134)
39
+ *
40
+ * No guarded operation needs `204`. Keeping the exemption meant a bodyless 204
41
+ * from a proxy, a CDN, or a CORS-mangled hop still read as "sent". Removed. If a
42
+ * future endpoint genuinely contracts `204`, add it back for that endpoint only,
43
+ * with its own predicate — not as a blanket transport exemption.
44
+ *
45
+ * ── Failures are TERMINAL ──────────────────────────────────────────────────
46
+ *
47
+ * Every throw here means "we cannot confirm this was stored", which is NOT the
48
+ * same as "this was not stored". Callers must surface it as a visible failure
49
+ * and must never retry: if the request *did* reach the API, a retry double-stores
50
+ * the lead; if it did not, a retry cannot help. Wording shown to the visitor says
51
+ * "could not confirm", never "not received".
52
+ */
53
+
54
+ /** A caller-supplied type predicate describing one endpoint's receipt. */
55
+ export type ReceiptValidator<T> = (value: unknown) => value is T
56
+
57
+ /**
58
+ * Error thrown when a submission cannot be confirmed. `receiptRejected`
59
+ * distinguishes the two causes, because they have different remedies:
60
+ *
61
+ * - `false` — transport failure. The body was not JSON at all (HTML shell,
62
+ * proxy interstitial, empty body, unreadable stream). Check routing.
63
+ * - `true` — the body WAS valid JSON but did not carry a receipt. Check that
64
+ * the endpoint contract and the predicate still agree.
65
+ */
66
+ export interface UnconfirmedSubmissionError extends Error {
67
+ receiptRejected?: boolean
68
+ }
69
+
70
+ /**
71
+ * Reads the body of an **ok** (2xx) submission response and refuses to treat a
72
+ * non-JSON body as a successful submission.
73
+ *
74
+ * Contract:
75
+ * - JSON body -> parsed value
76
+ * - anything else -> throws (empty, non-JSON content-type, unparseable,
77
+ * unreadable stream — including on `204`)
78
+ *
79
+ * @param label prefix for the console diagnostic, so a site copy can name itself.
80
+ */
81
+ export async function readOkBody(res: Response, label = LOG_PREFIX): Promise<unknown> {
82
+ // Non-`Response` fetch implementations (older test doubles) may expose only
83
+ // `json()`. Preserve the historical behaviour for them rather than throwing on
84
+ // a shape this package never controlled. NOTE: this path used to be a silent
85
+ // success hole — a rejected `json()` became `null`. It no longer is, because
86
+ // `readExpectedJson` runs a receipt predicate over whatever comes back and
87
+ // `null` satisfies no receipt.
88
+ if (typeof res.text !== 'function') {
89
+ try {
90
+ return await res.json()
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ const contentType = readContentType(res)
97
+ // Read as text FIRST: a body can only be consumed once, so calling `json()`
98
+ // first would leave nothing to inspect when it fails.
99
+ let raw: string
100
+ try {
101
+ raw = (await res.text()).trim()
102
+ } catch (readError) {
103
+ // We never saw the body — never claim success from that.
104
+ throw unreadableBodyError(label, res.status, readError)
105
+ }
106
+
107
+ if (raw === '') {
108
+ // C-310: `204` used to be exempt here. It is not any more — see the header.
109
+ throw emptyBodyError(label, res.status)
110
+ }
111
+
112
+ if (contentType && !contentType.includes('json')) {
113
+ throw nonJsonBodyError(label, res.status, contentType, raw)
114
+ }
115
+ try {
116
+ return JSON.parse(raw)
117
+ } catch {
118
+ throw nonJsonBodyError(label, res.status, contentType, raw)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Reads an **ok** (2xx) response and requires it to carry the receipt the
124
+ * endpoint contracts. Throws an {@link UnconfirmedSubmissionError} otherwise.
125
+ *
126
+ * Zero dependencies and no schema library on purpose: `site-forms` is a leaf
127
+ * package that sites pin independently, so anything that adds a dependency is
128
+ * unshippable to exactly the sites that need the fix most.
129
+ */
130
+ export async function readExpectedJson<T>(
131
+ res: Response,
132
+ validator: ReceiptValidator<T>,
133
+ options: { label?: string; receiptName?: string } = {},
134
+ ): Promise<T> {
135
+ const label = options.label ?? LOG_PREFIX
136
+ const body = await readOkBody(res, label)
137
+ if (validator(body)) return body
138
+ throw invalidReceiptError(label, res.status, options.receiptName ?? 'submission', body)
139
+ }
140
+
141
+ // ───────────────────────────────────────────────────────────────────────────
142
+ // Per-endpoint receipt predicates.
143
+ //
144
+ // One shared success contract across contact, estimate, authentication and
145
+ // cancellation would be a fiction, so transport parsing is shared and the
146
+ // receipt shape stays per-endpoint (Codex §a). Shapes are pinned to the REAL
147
+ // generated contract types in contracts/generated/typescript/types.gen.ts —
148
+ // deliberately re-declared here rather than imported, because customer sites
149
+ // install this package from npm and do not have @dcs/contracts.
150
+ // ───────────────────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * NO LEGACY SPAM-QUARANTINE EXEMPTION — removed by C-319, deliberately.
154
+ *
155
+ * C-310 widened both predicates below to accept a bare `{"status":"ok"}`,
156
+ * because two of the five spam-quarantine paths answered that instead of their
157
+ * endpoint's real receipt (C-307 held every quarantine response byte-identical
158
+ * to its pre-change self, and two of those shapes were already wrong). Without
159
+ * the widening, every quarantined submitter — including an AI false-positive
160
+ * victim whose message really was stored — would have seen a visible failure.
161
+ *
162
+ * C-319 removed the cause: all five quarantine paths now answer their route's
163
+ * GENUINE success receipt, built by the same writer the success path uses and
164
+ * populated from the row C-307 stores. So the widening is gone with it. A
165
+ * `{"status":"ok"}` body from any of these endpoints is now what it always
166
+ * looked like — a misroute — and must fail.
167
+ *
168
+ * ORDERING NOTE, because this package ships independently of the server: this
169
+ * removal is only safe while no DEPLOYED client carries these predicates.
170
+ * Verified at removal time — the predicates were introduced in 0.6.0 (C-310)
171
+ * and 0.6.0 has never been published (npm latest: 0.5.0), so every deployed
172
+ * site is on a version with no receipt validation at all. site-forms must not
173
+ * be published AHEAD of the server roll that carries C-319; both ride the same
174
+ * attended C-239 train.
175
+ */
176
+
177
+ /** `PublicSiteFormSubmissionResponse` — types.gen.ts:14845. */
178
+ export interface ManagedFormReceipt {
179
+ id?: string
180
+ status: string
181
+ submittedAt?: string
182
+ message?: string
183
+ contactMessageId?: string | null
184
+ notificationQueued?: boolean
185
+ }
186
+
187
+ const MANAGED_FORM_STATUSES = ['submitted', 'accepted', 'queued']
188
+
189
+ export function isManagedFormReceipt(value: unknown): value is ManagedFormReceipt {
190
+ return (
191
+ isRecord(value) &&
192
+ isNonEmptyString(value['id']) &&
193
+ isOneOf(value['status'], MANAGED_FORM_STATUSES) &&
194
+ isTimestamp(value['submittedAt'])
195
+ )
196
+ }
197
+
198
+ /** `ContactFormResponse` — types.gen.ts:19831. */
199
+ export interface SiteContactReceipt {
200
+ id?: string
201
+ status: string
202
+ submittedAt?: string
203
+ message?: string
204
+ }
205
+
206
+ const CONTACT_STATUSES = ['submitted', 'processing', 'responded']
207
+
208
+ export function isSiteContactReceipt(value: unknown): value is SiteContactReceipt {
209
+ return (
210
+ isRecord(value) &&
211
+ isNonEmptyString(value['id']) &&
212
+ isOneOf(value['status'], CONTACT_STATUSES)
213
+ )
214
+ }
215
+
216
+ /**
217
+ * Visitor estimate creation — `201` + the mapped estimate row
218
+ * (revenue_estimate_visitor.go:1419 `mapSiteVisitorEstimateResponse`). The
219
+ * estimate honeypot's decoy is built by the same row constructor and answered
220
+ * by the same writer a genuine estimate uses (C-319), so it carries a real ULID
221
+ * `id` and passes — which is the point: the honeypot must stay
222
+ * indistinguishable from a genuine submission.
223
+ */
224
+ export interface EstimateReceipt {
225
+ id: string
226
+ status?: string
227
+ }
228
+
229
+ export function isEstimateReceipt(value: unknown): value is EstimateReceipt {
230
+ return isRecord(value) && isNonEmptyString(value['id'])
231
+ }
232
+
233
+ /** `PasswordlessVerifyCodeResponse` — types.gen.ts:20780. */
234
+ export interface PasswordlessVerifyReceipt {
235
+ success: true
236
+ redirectTo?: string
237
+ }
238
+
239
+ export function isPasswordlessVerifyReceipt(value: unknown): value is PasswordlessVerifyReceipt {
240
+ return isRecord(value) && value['success'] === true
241
+ }
242
+
243
+ /** `CancelBookingResponse` — types.gen.ts:21486. */
244
+ export interface BookingCancellationReceipt {
245
+ success: true
246
+ refundAmountCents?: number | null
247
+ message?: string
248
+ }
249
+
250
+ export function isBookingCancellationReceipt(value: unknown): value is BookingCancellationReceipt {
251
+ return isRecord(value) && value['success'] === true
252
+ }
253
+
254
+ // ───────────────────────────────────────────────────────────────────────────
255
+ // Internals
256
+ // ───────────────────────────────────────────────────────────────────────────
257
+
258
+ const LOG_PREFIX = '[@duffcloudservices/site-forms]'
259
+
260
+ /**
261
+ * The one honest sentence a visitor is shown for any unconfirmable 2xx. The
262
+ * technical diagnosis belongs in the console for whoever maintains the site —
263
+ * never on the page. "Could not confirm" is deliberate: a post-storage body
264
+ * failure means UNKNOWN, not "not stored".
265
+ */
266
+ const VISITOR_UNCONFIRMED_MESSAGE =
267
+ 'We could not confirm your message was received. Please try again, or contact us directly.'
268
+
269
+ function readContentType(res: Response): string {
270
+ const get = res.headers?.get
271
+ if (typeof get !== 'function') return ''
272
+ return (res.headers.get('content-type') ?? '').toLowerCase()
273
+ }
274
+
275
+ function isRecord(value: unknown): value is Record<string, unknown> {
276
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
277
+ }
278
+
279
+ function isNonEmptyString(value: unknown): value is string {
280
+ return typeof value === 'string' && value.trim().length > 0
281
+ }
282
+
283
+ function isOneOf(value: unknown, allowed: string[]): boolean {
284
+ return typeof value === 'string' && allowed.includes(value)
285
+ }
286
+
287
+ function isTimestamp(value: unknown): boolean {
288
+ return isNonEmptyString(value) && Number.isFinite(Date.parse(value))
289
+ }
290
+
291
+ function unconfirmed(message: string, receiptRejected: boolean): UnconfirmedSubmissionError {
292
+ const err = new Error(message) as UnconfirmedSubmissionError
293
+ err.receiptRejected = receiptRejected
294
+ return err
295
+ }
296
+
297
+ function unreadableBodyError(label: string, status: number, cause: unknown): UnconfirmedSubmissionError {
298
+ const detail = cause instanceof Error ? cause.message : String(cause)
299
+ // eslint-disable-next-line no-console
300
+ console.error(
301
+ `${label} Submission could not be confirmed: the server answered ${status} but the response ` +
302
+ `body could not be read (${detail}). The submission may or may not have been stored, so it ` +
303
+ `is reported as a failure rather than risk telling the visitor it was sent.`,
304
+ )
305
+ return unconfirmed(VISITOR_UNCONFIRMED_MESSAGE, false)
306
+ }
307
+
308
+ function emptyBodyError(label: string, status: number): UnconfirmedSubmissionError {
309
+ // eslint-disable-next-line no-console
310
+ console.error(
311
+ `${label} Submission NOT confirmed: the server answered ${status} with an empty body. ` +
312
+ `EVERY DCS submission endpoint returns a JSON receipt — including on 204, which is why the ` +
313
+ `old 204 exemption was removed (C-310) — so an empty body means the request was answered by ` +
314
+ `something other than the DCS API (a misrouted POST).`,
315
+ )
316
+ return unconfirmed(VISITOR_UNCONFIRMED_MESSAGE, false)
317
+ }
318
+
319
+ function nonJsonBodyError(
320
+ label: string,
321
+ status: number,
322
+ contentType: string,
323
+ raw: string,
324
+ ): UnconfirmedSubmissionError {
325
+ const looksLikeHtml = contentType.includes('html') || /^\s*<(?:!doctype|html)/i.test(raw)
326
+ // eslint-disable-next-line no-console
327
+ console.error(
328
+ `${label} Submission NOT stored: the server answered ${status} ` +
329
+ `with a ${contentType || 'bodyless/unknown'} body instead of JSON` +
330
+ (looksLikeHtml
331
+ ? ' — this is an HTML page, so the POST did not reach the DCS API (check the resolved api-base and the host routing for /api/v1/*).'
332
+ : '.') +
333
+ ` First 200 bytes: ${raw.slice(0, 200)}`,
334
+ )
335
+ return unconfirmed(VISITOR_UNCONFIRMED_MESSAGE, false)
336
+ }
337
+
338
+ function invalidReceiptError(
339
+ label: string,
340
+ status: number,
341
+ receiptName: string,
342
+ body: unknown,
343
+ ): UnconfirmedSubmissionError {
344
+ // eslint-disable-next-line no-console
345
+ console.error(
346
+ `${label} Submission NOT confirmed: the server answered ${status} with JSON that is not a ` +
347
+ `valid ${receiptName} receipt. A 2xx and parseable JSON prove only that SOMETHING answered; ` +
348
+ `only the endpoint's receipt proves a row was stored. Received: ` +
349
+ `${safeStringify(body).slice(0, 200)}`,
350
+ )
351
+ return unconfirmed(VISITOR_UNCONFIRMED_MESSAGE, true)
352
+ }
353
+
354
+ function safeStringify(value: unknown): string {
355
+ try {
356
+ return JSON.stringify(value) ?? String(value)
357
+ } catch {
358
+ return String(value)
359
+ }
360
+ }
@@ -3,6 +3,11 @@ import type {
3
3
  DcsFormSubmitSuccess,
4
4
  DcsFormSubmitError,
5
5
  } from '../types'
6
+ import {
7
+ isManagedFormReceipt,
8
+ readExpectedJson,
9
+ type UnconfirmedSubmissionError,
10
+ } from './readExpectedJson'
6
11
 
7
12
  export interface SubmitOptions {
8
13
  apiBase: string
@@ -45,16 +50,43 @@ export async function submitFormValues(
45
50
  for (let attempt = 0; attempt <= retries; attempt++) {
46
51
  try {
47
52
  const init: RequestInit = hasFile
48
- ? { method: 'POST', body: buildFormData(payload) }
53
+ ? { method: 'POST', redirect: REDIRECT_MODE, body: buildFormData(payload) }
49
54
  : {
50
55
  method: 'POST',
56
+ redirect: REDIRECT_MODE,
51
57
  headers: { 'Content-Type': 'application/json' },
52
58
  body: JSON.stringify(payload),
53
59
  }
54
60
  const res = await fetchImpl(url, init)
55
61
  lastStatus = res.status
56
62
  if (res.ok) {
57
- const body = await safeJson(res)
63
+ let body: unknown
64
+ try {
65
+ body = await readExpectedJson(res, isManagedFormReceipt, {
66
+ receiptName: 'managed-form submission',
67
+ })
68
+ } catch (bodyError) {
69
+ // TERMINAL — never retried. A 2xx that does not carry the endpoint's
70
+ // receipt means either the POST was answered by something that is not
71
+ // the DCS API (SPA shell, proxy interstitial, gateway error page) or
72
+ // the API answered with something that does not prove a row exists.
73
+ // Retrying could double-store the lead if the request *did* reach the
74
+ // API, and cannot help if it did not. Surface it as a failure so the
75
+ // visitor is never told "sent" without a receipt (C-301 / C-310).
76
+ const receiptRejected = Boolean(
77
+ (bodyError as UnconfirmedSubmissionError)?.receiptRejected,
78
+ )
79
+ throw {
80
+ payload,
81
+ status: res.status,
82
+ error: bodyError as Error,
83
+ // `nonJsonResponse` keeps its original literal meaning (the body was
84
+ // not JSON at all); a JSON body that failed the receipt predicate is
85
+ // reported through the additive `unconfirmedReceipt` flag instead.
86
+ nonJsonResponse: !receiptRejected,
87
+ unconfirmedReceipt: receiptRejected,
88
+ } satisfies DcsFormSubmitError
89
+ }
58
90
  return { payload, response: body }
59
91
  }
60
92
  // Retry only on 5xx
@@ -117,13 +149,27 @@ function valueHasFile(value: unknown): boolean {
117
149
  return Array.isArray(value) && value.some((item) => item instanceof File)
118
150
  }
119
151
 
120
- async function safeJson(res: Response): Promise<unknown> {
121
- try {
122
- return await res.json()
123
- } catch {
124
- return null
125
- }
126
- }
152
+ /**
153
+ * A submission POST must never FOLLOW a redirect (C-310, Codex review §b).
154
+ *
155
+ * Browser `fetch` follows redirects by default, so the guard only ever sees the
156
+ * FINAL response. An HTML landing page is caught by the receipt check, but a
157
+ * redirect that terminates at some other host's valid JSON would not be — and a
158
+ * `301`/`302` also silently rewrites a POST into a GET, so the submission body
159
+ * is dropped on the way.
160
+ *
161
+ * Verified against production routing before enabling: the Front Door `api`
162
+ * route (`infrastructure/partner/front-door-routes.bicep:1626`) and the portal
163
+ * `/api/*` route (`:2318`) carry NO rule sets — their only redirect is
164
+ * `httpsRedirect`, which cannot fire because every fleet site resolves an
165
+ * absolute `https://` api-base (C-301 live-bundle sweep). The one `/*`-scoped
166
+ * 301 in the profile (`kduff-homes-www-redirect`, `:2125`) applies to
167
+ * `www.kimduffhomes.com` and is a MISROUTE by definition for a submission POST.
168
+ *
169
+ * So on the intended path this changes nothing, and on every unintended path it
170
+ * converts a silent, body-losing hop into a visible failure.
171
+ */
172
+ const REDIRECT_MODE: RequestRedirect = 'error'
127
173
 
128
174
  async function safeText(res: Response): Promise<string> {
129
175
  try {