@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 CHANGED
@@ -165,10 +165,34 @@ import { dcsFormsModules } from '@/dcs-forms'
165
165
 
166
166
  | Event | Payload | When |
167
167
  | ------------------ | ---------------------- | ------------------------------------------- |
168
- | `submit-success` | `DcsFormSubmitSuccess` | API responded `2xx`. |
169
- | `submit-error` | `DcsFormSubmitError` | Network or non-2xx response after retries. |
168
+ | `submit-success` | `DcsFormSubmitSuccess` | API responded `2xx` **with a JSON (or empty) body**. |
169
+ | `submit-error` | `DcsFormSubmitError` | Network error, non-2xx after retries, **or a 2xx whose body is not JSON** (`nonJsonResponse: true`). |
170
170
  | `validation-error` | `FormErrors` | Submit attempted with invalid required/regex/etc. fields. |
171
171
 
172
+ ### A 2xx with a non-JSON body is a FAILURE (0.6.0, C-301)
173
+
174
+ Before 0.6.0 the submission helper swallowed every body-parse failure and
175
+ returned `null`, so a POST answered by **HTTP 200 `text/html`** — a static
176
+ host's SPA shell, a Front Door catch-all, a captive-portal or corporate-proxy
177
+ interstitial — was indistinguishable from a quiet success. The visitor saw the
178
+ success state and the success chime while **nothing was stored**: silent lead
179
+ loss.
180
+
181
+ Now:
182
+
183
+ | Response | Behaviour |
184
+ | ------------------------------ | ------------------------------------------------------ |
185
+ | `2xx` + JSON body | success, `response` = parsed JSON (unchanged) |
186
+ | `2xx` + empty body (incl. 204) | success, `response` = `null` (unchanged) |
187
+ | `2xx` + any other body | **`submit-error`** with `nonJsonResponse: true`, **never retried** (retrying could double-store a lead), plus a `console.error` naming the resolved base |
188
+ | non-2xx | unchanged error contract |
189
+
190
+ The assertion is inlined here on purpose rather than delegated to
191
+ `@duffcloudservices/cms-core`'s `platformFetch`: `site-forms` is a leaf package
192
+ that sites pin independently (coron8 sits on `0.1.4`), and a new cms-core
193
+ dependency would make the fix unpublishable to exactly the sites that most need
194
+ it.
195
+
172
196
  ## Slots
173
197
 
174
198
  Every slot exposes scoped data so consumers (KT Braun, Kept) can swap
@@ -207,6 +231,11 @@ import {
207
231
  - `submitFormValues({ apiBase, siteSlug, payload })` — one-shot POST
208
232
  with a single retry on 5xx and `multipart/form-data` when files are
209
233
  present.
234
+ - `fetchSiteHandlesPhi({ apiBase, siteSlug })` — the site's PHI posture
235
+ (C-415), memoized per site. `<DcsForm/>` already calls it; a custom
236
+ layout should use it rather than inventing a second HIPAA signal.
237
+ `PHI_GUIDANCE_COPY` is the standard wording, `resetSitePhiPostureCache()`
238
+ clears the memo (tests / the portal preview iframe).
210
239
 
211
240
  File fields emit a single `File` by default. When `attachmentPolicy.maxFiles`
212
241
  is greater than `1`, the file field emits `File[]`, enables multiple
@@ -285,7 +314,17 @@ Use the **Legal Intake — Standard** form template (created via the portal Form
285
314
  - Automatically sets `isSensitive: true`
286
315
  - Includes matter-type and adverse-parties fields for conflict screening
287
316
 
288
- For healthcare intake, mark the form as `FormKind: "hipaa"` so the submission handler applies PHI-aware redaction for notification emails.
317
+ For healthcare intake, mark each PHI-bearing field `phi: true` in the YAML. There is no `hipaa` form kind (the kinds are `freeform`, `contact`, `revenue-contractor`, `resume-submission`); PHI handling is driven by the per-field `phi` marker plus the **site-level** predicate below.
318
+
319
+ ### PHI guidance on HIPAA-mode tenants (C-415)
320
+
321
+ On a HIPAA-mode tenant, `<DcsForm/>` renders a standard guidance line immediately above the first free-text field:
322
+
323
+ > **Please don't include personal health details.** A general description of what you need is enough — we'll go over anything specific with you directly.
324
+
325
+ It is on by default and there is deliberately **no prop and no YAML flag** to force it on or off. The verdict comes from the platform: on mount the component GETs `/api/v1/sites/{slug}/forms/compliance` (memoized per site; the slug-free `/api/v1/forms/compliance` when the build has no baked slug), which is a boolean projection of the server's one site-level PHI predicate, `portal.Service.SiteHandlesPHI` — authoritative input `SiteAgentConfigs/{slug}/default.HipaaMode`, the same row the C-352 redaction gate reads. A site-local switch could disagree with the tenant's real posture, which is exactly the class C-352 fixed.
326
+
327
+ If the read fails the component renders nothing; the server-side redactions remain the actual control. Style it via `.dcs-form__phi-guidance`; probe for it with `[data-form-phi-guidance]` (the copy contains no "HIPAA"/"protected health information" legalese by design — it is guidance for a visitor, not a legal notice).
289
328
 
290
329
  ---
291
330
 
@@ -0,0 +1,158 @@
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
+ /** A caller-supplied type predicate describing one endpoint's receipt. */
54
+ export type ReceiptValidator<T> = (value: unknown) => value is T;
55
+ /**
56
+ * Error thrown when a submission cannot be confirmed. `receiptRejected`
57
+ * distinguishes the two causes, because they have different remedies:
58
+ *
59
+ * - `false` — transport failure. The body was not JSON at all (HTML shell,
60
+ * proxy interstitial, empty body, unreadable stream). Check routing.
61
+ * - `true` — the body WAS valid JSON but did not carry a receipt. Check that
62
+ * the endpoint contract and the predicate still agree.
63
+ */
64
+ export interface UnconfirmedSubmissionError extends Error {
65
+ receiptRejected?: boolean;
66
+ }
67
+ /**
68
+ * Reads the body of an **ok** (2xx) submission response and refuses to treat a
69
+ * non-JSON body as a successful submission.
70
+ *
71
+ * Contract:
72
+ * - JSON body -> parsed value
73
+ * - anything else -> throws (empty, non-JSON content-type, unparseable,
74
+ * unreadable stream — including on `204`)
75
+ *
76
+ * @param label prefix for the console diagnostic, so a site copy can name itself.
77
+ */
78
+ export declare function readOkBody(res: Response, label?: string): Promise<unknown>;
79
+ /**
80
+ * Reads an **ok** (2xx) response and requires it to carry the receipt the
81
+ * endpoint contracts. Throws an {@link UnconfirmedSubmissionError} otherwise.
82
+ *
83
+ * Zero dependencies and no schema library on purpose: `site-forms` is a leaf
84
+ * package that sites pin independently, so anything that adds a dependency is
85
+ * unshippable to exactly the sites that need the fix most.
86
+ */
87
+ export declare function readExpectedJson<T>(res: Response, validator: ReceiptValidator<T>, options?: {
88
+ label?: string;
89
+ receiptName?: string;
90
+ }): Promise<T>;
91
+ /**
92
+ * NO LEGACY SPAM-QUARANTINE EXEMPTION — removed by C-319, deliberately.
93
+ *
94
+ * C-310 widened both predicates below to accept a bare `{"status":"ok"}`,
95
+ * because two of the five spam-quarantine paths answered that instead of their
96
+ * endpoint's real receipt (C-307 held every quarantine response byte-identical
97
+ * to its pre-change self, and two of those shapes were already wrong). Without
98
+ * the widening, every quarantined submitter — including an AI false-positive
99
+ * victim whose message really was stored — would have seen a visible failure.
100
+ *
101
+ * C-319 removed the cause: all five quarantine paths now answer their route's
102
+ * GENUINE success receipt, built by the same writer the success path uses and
103
+ * populated from the row C-307 stores. So the widening is gone with it. A
104
+ * `{"status":"ok"}` body from any of these endpoints is now what it always
105
+ * looked like — a misroute — and must fail.
106
+ *
107
+ * ORDERING NOTE, because this package ships independently of the server: this
108
+ * removal is only safe while no DEPLOYED client carries these predicates.
109
+ * Verified at removal time — the predicates were introduced in 0.6.0 (C-310)
110
+ * and 0.6.0 has never been published (npm latest: 0.5.0), so every deployed
111
+ * site is on a version with no receipt validation at all. site-forms must not
112
+ * be published AHEAD of the server roll that carries C-319; both ride the same
113
+ * attended C-239 train.
114
+ */
115
+ /** `PublicSiteFormSubmissionResponse` — types.gen.ts:14845. */
116
+ export interface ManagedFormReceipt {
117
+ id?: string;
118
+ status: string;
119
+ submittedAt?: string;
120
+ message?: string;
121
+ contactMessageId?: string | null;
122
+ notificationQueued?: boolean;
123
+ }
124
+ export declare function isManagedFormReceipt(value: unknown): value is ManagedFormReceipt;
125
+ /** `ContactFormResponse` — types.gen.ts:19831. */
126
+ export interface SiteContactReceipt {
127
+ id?: string;
128
+ status: string;
129
+ submittedAt?: string;
130
+ message?: string;
131
+ }
132
+ export declare function isSiteContactReceipt(value: unknown): value is SiteContactReceipt;
133
+ /**
134
+ * Visitor estimate creation — `201` + the mapped estimate row
135
+ * (revenue_estimate_visitor.go:1419 `mapSiteVisitorEstimateResponse`). The
136
+ * estimate honeypot's decoy is built by the same row constructor and answered
137
+ * by the same writer a genuine estimate uses (C-319), so it carries a real ULID
138
+ * `id` and passes — which is the point: the honeypot must stay
139
+ * indistinguishable from a genuine submission.
140
+ */
141
+ export interface EstimateReceipt {
142
+ id: string;
143
+ status?: string;
144
+ }
145
+ export declare function isEstimateReceipt(value: unknown): value is EstimateReceipt;
146
+ /** `PasswordlessVerifyCodeResponse` — types.gen.ts:20780. */
147
+ export interface PasswordlessVerifyReceipt {
148
+ success: true;
149
+ redirectTo?: string;
150
+ }
151
+ export declare function isPasswordlessVerifyReceipt(value: unknown): value is PasswordlessVerifyReceipt;
152
+ /** `CancelBookingResponse` — types.gen.ts:21486. */
153
+ export interface BookingCancellationReceipt {
154
+ success: true;
155
+ refundAmountCents?: number | null;
156
+ message?: string;
157
+ }
158
+ export declare function isBookingCancellationReceipt(value: unknown): value is BookingCancellationReceipt;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Site PHI posture — the ONE platform signal that decides whether a managed
3
+ * form shows PHI guidance to the visitor.
4
+ *
5
+ * WHY THIS EXISTS (C-415 / KEPT re-review F1). On a HIPAA-mode tenant the AI
6
+ * chat widget already opens with "please don't include personal health
7
+ * details", because `/agent/config` projects `hipaaMode` to it. The managed
8
+ * form on the same tenant — the higher-volume intake path, with a required
9
+ * free-text message box — projected nothing, so the platform was doing the
10
+ * hard half of the problem (redacting PHI it had already received) and
11
+ * skipping the cheap half (asking the visitor not to send it).
12
+ *
13
+ * THE SIGNAL, AND ONLY THIS SIGNAL. `GET {apiBase}/sites/{slug}/forms/compliance`
14
+ * returns `{ siteHandlesPhi }`, the server's projection of
15
+ * `portal.Service.SiteHandlesPHI` — whose authoritative input is
16
+ * `SiteAgentConfigs/{slug}/default.HipaaMode`, the same admin-pinned row the
17
+ * C-352 redaction gate reads. There is no second signal, no YAML flag, and no
18
+ * per-site opt-in: a site-local switch could drift from the tenant's real HIPAA
19
+ * posture, which is the exact class of bug C-352 fixed.
20
+ *
21
+ * NOT `/agent/config`, which already carries `hipaaMode`: that endpoint 404s
22
+ * unless the `aiAgent` feature, the per-site toggle, and the global kill switch
23
+ * are all on. Reading the flag from there would silently give no guidance to a
24
+ * HIPAA-mode tenant that does not run the chat widget — a fail-OPEN gate hidden
25
+ * behind an unrelated feature flag.
26
+ *
27
+ * FAIL DIRECTION. The server side fails CLOSED (an unresolvable HipaaMode
28
+ * reports `true`, so the guidance shows). The client cannot: a transport
29
+ * failure or an offline build has no verdict at all, and defaulting to "show"
30
+ * would put a health-privacy line on every non-medical customer's contact form.
31
+ * So the client defaults to `false` on any failure, and the server-side
32
+ * redactions (C-352) remain the actual control — this module is guidance, not
33
+ * enforcement.
34
+ */
35
+ export interface SitePhiPostureOptions {
36
+ /** API base URL, e.g. `https://api.duffcloudservices.com/api/v1`. */
37
+ apiBase: string;
38
+ /**
39
+ * Site slug. When empty the slug-free `/forms/compliance` route is used and
40
+ * the server infers the site from the request origin/host — the same
41
+ * fallback `submitFormValues` uses, so a build with no baked slug still gets
42
+ * a verdict instead of requesting the broken `/sites//forms/...` shape.
43
+ */
44
+ siteSlug: string;
45
+ /** Optional fetch implementation override (tests). */
46
+ fetchImpl?: typeof fetch;
47
+ }
48
+ /** Clears the memoized verdicts. Exported for tests and the portal preview iframe. */
49
+ export declare function resetSitePhiPostureCache(): void;
50
+ /**
51
+ * Resolves whether this site's managed-form submissions are PHI-bearing.
52
+ *
53
+ * Returns `false` on any failure (network, non-2xx, non-JSON, or a body whose
54
+ * `siteHandlesPhi` is not a boolean). A missing/garbled body is treated as "no
55
+ * verdict", never as an implicit yes — see the fail-direction note above.
56
+ */
57
+ export declare function fetchSiteHandlesPhi(opts: SitePhiPostureOptions): Promise<boolean>;
58
+ /**
59
+ * The standard PHI guidance copy. It lives in the package, not in the API
60
+ * response and not in any site's YAML, so one platform decision renders the
61
+ * same sentence on every HIPAA-mode tenant.
62
+ *
63
+ * It is guidance, not a legal notice: it tells the visitor what to do instead
64
+ * of what the law requires, and it deliberately mirrors the sentence the AI
65
+ * chat already ships on these tenants ("please don't include personal health
66
+ * details") so the two surfaces read as one product.
67
+ */
68
+ export declare const PHI_GUIDANCE_COPY: {
69
+ readonly lead: "Please don't include personal health details.";
70
+ readonly detail: "A general description of what you need is enough — we'll go over anything specific with you directly.";
71
+ };
package/dist/index.d.ts CHANGED
@@ -16,6 +16,10 @@ export type { UseDcsFormOptions, UseDcsFormReturn } from './composables/useDcsFo
16
16
  export { validateField, validateForm, isFieldVisible, hasErrors, } from './composables/useFormValidation';
17
17
  export { submitFormValues } from './composables/useFormSubmission';
18
18
  export type { SubmitOptions } from './composables/useFormSubmission';
19
+ export { fetchSiteHandlesPhi, resetSitePhiPostureCache, PHI_GUIDANCE_COPY, } from './composables/useSitePhiPosture';
20
+ export type { SitePhiPostureOptions } from './composables/useSitePhiPosture';
21
+ export { readOkBody, readExpectedJson, isManagedFormReceipt, isSiteContactReceipt, isEstimateReceipt, isPasswordlessVerifyReceipt, isBookingCancellationReceipt, } from './composables/readExpectedJson';
22
+ export type { ReceiptValidator, UnconfirmedSubmissionError, ManagedFormReceipt, SiteContactReceipt, EstimateReceipt, PasswordlessVerifyReceipt, BookingCancellationReceipt, } from './composables/readExpectedJson';
19
23
  export { loadFormDefinitions, parseFormYaml } from './loaders/yaml';
20
24
  export { buildStandardFormDefinition, STANDARD_FORM_PRESET_META, } from './presets';
21
25
  export type { StandardFormPreset, BuildStandardFormOptions, } from './presets';