@duffcloudservices/site-forms 0.5.0 → 0.7.0
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.
- package/README.md +42 -3
- package/dist/composables/readExpectedJson.d.ts +158 -0
- package/dist/composables/useSitePhiPosture.d.ts +71 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +753 -588
- package/dist/index.js.map +1 -1
- package/dist/site-forms.css +1 -1
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/DcsForm.vue +61 -10
- package/src/__tests__/missing-form-error-surfacing.test.ts +7 -2
- package/src/__tests__/non-json-response.test.ts +282 -0
- package/src/__tests__/phi-guidance.test.ts +239 -0
- package/src/__tests__/receipt-validation.test.ts +425 -0
- package/src/__tests__/submission.test.ts +23 -6
- package/src/__tests__/visible-if.test.ts +17 -4
- package/src/composables/readExpectedJson.ts +360 -0
- package/src/composables/useFormSubmission.ts +55 -9
- package/src/composables/useSitePhiPosture.ts +123 -0
- package/src/index.ts +29 -0
- package/src/style.css +25 -0
- package/src/types.ts +16 -0
|
@@ -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 () =>
|
|
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
|
-
|
|
46
|
-
|
|
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 () =>
|
|
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 () =>
|
|
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 () =>
|
|
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 () =>
|
|
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
|
-
|
|
60
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|