@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.
@@ -0,0 +1,123 @@
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
+
36
+ export interface SitePhiPostureOptions {
37
+ /** API base URL, e.g. `https://api.duffcloudservices.com/api/v1`. */
38
+ apiBase: string
39
+ /**
40
+ * Site slug. When empty the slug-free `/forms/compliance` route is used and
41
+ * the server infers the site from the request origin/host — the same
42
+ * fallback `submitFormValues` uses, so a build with no baked slug still gets
43
+ * a verdict instead of requesting the broken `/sites//forms/...` shape.
44
+ */
45
+ siteSlug: string
46
+ /** Optional fetch implementation override (tests). */
47
+ fetchImpl?: typeof fetch
48
+ }
49
+
50
+ /** Shape of `GET /sites/{siteSlug}/forms/compliance`. */
51
+ interface SiteFormComplianceResponse {
52
+ siteHandlesPhi?: unknown
53
+ }
54
+
55
+ /**
56
+ * In-flight/settled verdicts keyed by `${base}|${slug}`. A page can host several
57
+ * `<DcsForm>`s (a contact form plus an intake questionnaire); they must not each
58
+ * pay for a round trip, and they must not be able to disagree with each other.
59
+ */
60
+ const postureCache = new Map<string, Promise<boolean>>()
61
+
62
+ /** Clears the memoized verdicts. Exported for tests and the portal preview iframe. */
63
+ export function resetSitePhiPostureCache(): void {
64
+ postureCache.clear()
65
+ }
66
+
67
+ /**
68
+ * Resolves whether this site's managed-form submissions are PHI-bearing.
69
+ *
70
+ * Returns `false` on any failure (network, non-2xx, non-JSON, or a body whose
71
+ * `siteHandlesPhi` is not a boolean). A missing/garbled body is treated as "no
72
+ * verdict", never as an implicit yes — see the fail-direction note above.
73
+ */
74
+ export async function fetchSiteHandlesPhi(
75
+ opts: SitePhiPostureOptions,
76
+ ): Promise<boolean> {
77
+ const base = (opts.apiBase ?? '').replace(/\/$/, '')
78
+ const slug = (opts.siteSlug ?? '').trim()
79
+ const url = slug
80
+ ? `${base}/sites/${encodeURIComponent(slug)}/forms/compliance`
81
+ : `${base}/forms/compliance`
82
+
83
+ const cacheKey = `${base}|${slug}`
84
+ const cached = postureCache.get(cacheKey)
85
+ if (cached) return cached
86
+
87
+ const fetchImpl = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined)
88
+ if (!fetchImpl) return false
89
+
90
+ const pending = (async (): Promise<boolean> => {
91
+ try {
92
+ const res = await fetchImpl(url, { method: 'GET' })
93
+ if (!res.ok) return false
94
+ const body = (await res.json()) as SiteFormComplianceResponse
95
+ return body?.siteHandlesPhi === true
96
+ } catch {
97
+ // Deliberately silent: the only console output this package emits is the
98
+ // missing-definition warning (see the PHI-log grep gate in the site-forms
99
+ // skill). A failed posture read must never become a visitor-visible error
100
+ // on an otherwise working form.
101
+ return false
102
+ }
103
+ })()
104
+
105
+ postureCache.set(cacheKey, pending)
106
+ return pending
107
+ }
108
+
109
+ /**
110
+ * The standard PHI guidance copy. It lives in the package, not in the API
111
+ * response and not in any site's YAML, so one platform decision renders the
112
+ * same sentence on every HIPAA-mode tenant.
113
+ *
114
+ * It is guidance, not a legal notice: it tells the visitor what to do instead
115
+ * of what the law requires, and it deliberately mirrors the sentence the AI
116
+ * chat already ships on these tenants ("please don't include personal health
117
+ * details") so the two surfaces read as one product.
118
+ */
119
+ export const PHI_GUIDANCE_COPY = {
120
+ lead: "Please don't include personal health details.",
121
+ detail:
122
+ "A general description of what you need is enough — we'll go over anything specific with you directly.",
123
+ } as const
package/src/index.ts CHANGED
@@ -24,6 +24,35 @@ export {
24
24
  } from './composables/useFormValidation'
25
25
  export { submitFormValues } from './composables/useFormSubmission'
26
26
  export type { SubmitOptions } from './composables/useFormSubmission'
27
+ // C-415 — the site PHI posture read that decides whether <DcsForm> shows PHI
28
+ // guidance. Exported so a site can read the same verdict for its own copy;
29
+ // there is deliberately NO prop or YAML flag that forces it either way.
30
+ export {
31
+ fetchSiteHandlesPhi,
32
+ resetSitePhiPostureCache,
33
+ PHI_GUIDANCE_COPY,
34
+ } from './composables/useSitePhiPosture'
35
+ export type { SitePhiPostureOptions } from './composables/useSitePhiPosture'
36
+ // C-310 — the CANONICAL receipt primitive. Site-local backports (lamphere,
37
+ // iron-oak, web/, portal/) track this module; see its header for the contract.
38
+ export {
39
+ readOkBody,
40
+ readExpectedJson,
41
+ isManagedFormReceipt,
42
+ isSiteContactReceipt,
43
+ isEstimateReceipt,
44
+ isPasswordlessVerifyReceipt,
45
+ isBookingCancellationReceipt,
46
+ } from './composables/readExpectedJson'
47
+ export type {
48
+ ReceiptValidator,
49
+ UnconfirmedSubmissionError,
50
+ ManagedFormReceipt,
51
+ SiteContactReceipt,
52
+ EstimateReceipt,
53
+ PasswordlessVerifyReceipt,
54
+ BookingCancellationReceipt,
55
+ } from './composables/readExpectedJson'
27
56
 
28
57
  export { loadFormDefinitions, parseFormYaml } from './loaders/yaml'
29
58
  export {
package/src/style.css CHANGED
@@ -31,6 +31,30 @@
31
31
  gap: 1rem;
32
32
  }
33
33
 
34
+ /*
35
+ * PHI guidance (C-415). Rendered only when the platform reports the tenant as
36
+ * PHI-handling; sits directly above the first free-text field. Styled as a calm
37
+ * aside, not an alert: it is asking the visitor to phrase something differently,
38
+ * not warning them that something went wrong. Colours follow the rest of this
39
+ * file (translucent slate over the host page's own surface) so it inherits every
40
+ * customer brand without knowing any of them.
41
+ */
42
+ .dcs-form__phi-guidance {
43
+ margin: 0;
44
+ padding: 0.7rem 0.9rem;
45
+ font-size: 0.9rem;
46
+ line-height: 1.55;
47
+ color: inherit;
48
+ background: rgba(15, 23, 42, 0.045);
49
+ border: 1px solid rgba(15, 23, 42, 0.1);
50
+ border-left: 3px solid rgba(37, 99, 235, 0.6);
51
+ border-radius: 0.625rem;
52
+ }
53
+
54
+ .dcs-form__phi-guidance strong {
55
+ font-weight: 650;
56
+ }
57
+
34
58
  .dcs-form-field {
35
59
  display: grid;
36
60
  gap: 0.5rem;
@@ -338,6 +362,7 @@
338
362
  grid-column: span 1;
339
363
  }
340
364
 
365
+ .dcs-form__phi-guidance,
341
366
  .dcs-form-field--width-full,
342
367
  .dcs-form-field--width-auto,
343
368
  .dcs-form-field--textarea,
package/src/types.ts CHANGED
@@ -161,4 +161,20 @@ export interface DcsFormSubmitError {
161
161
  payload: DcsFormSubmitPayload
162
162
  error: Error
163
163
  status?: number
164
+ /**
165
+ * True when the server answered 2xx but the body was NOT JSON — i.e. the POST
166
+ * was answered by something that is not the DCS API (SPA shell, proxy
167
+ * interstitial, gateway page) and the submission was NOT stored. Additive and
168
+ * optional: the pre-existing error contract is unchanged (C-301).
169
+ */
170
+ nonJsonResponse?: boolean
171
+ /**
172
+ * True when the server answered 2xx with valid JSON that is NOT the endpoint's
173
+ * receipt (C-310). Distinct from `nonJsonResponse` because the remedies differ:
174
+ * that one means "the POST was answered by something else — check routing",
175
+ * this one means "the API answered, but with nothing that proves a row exists
176
+ * — check the endpoint contract against the receipt predicate". Additive and
177
+ * optional; the pre-existing error contract is unchanged.
178
+ */
179
+ unconfirmedReceipt?: boolean
164
180
  }