@nodii/onboarding 0.0.1 → 0.1.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.
Files changed (73) hide show
  1. package/README.md +75 -2
  2. package/dist/chunk-7NS7ENJV.js +232 -0
  3. package/dist/chunk-7NS7ENJV.js.map +1 -0
  4. package/dist/chunk-AZE3NPSY.js +186 -0
  5. package/dist/chunk-AZE3NPSY.js.map +1 -0
  6. package/dist/chunk-BMQINH6O.js +401 -0
  7. package/dist/chunk-BMQINH6O.js.map +1 -0
  8. package/dist/chunk-F75ZJERM.js +199 -0
  9. package/dist/chunk-F75ZJERM.js.map +1 -0
  10. package/dist/chunk-FIRSA7EH.js +3 -0
  11. package/dist/chunk-FIRSA7EH.js.map +1 -0
  12. package/dist/chunk-IPAKPLIF.js +1660 -0
  13. package/dist/chunk-IPAKPLIF.js.map +1 -0
  14. package/dist/chunk-JFDQZ3AC.js +3 -0
  15. package/dist/chunk-JFDQZ3AC.js.map +1 -0
  16. package/dist/chunk-JFIONONR.js +3 -0
  17. package/dist/chunk-JFIONONR.js.map +1 -0
  18. package/dist/chunk-KQTS47FC.js +546 -0
  19. package/dist/chunk-KQTS47FC.js.map +1 -0
  20. package/dist/chunk-LVJM4CTG.js +306 -0
  21. package/dist/chunk-LVJM4CTG.js.map +1 -0
  22. package/dist/chunk-SEKNVHD3.js +59 -0
  23. package/dist/chunk-SEKNVHD3.js.map +1 -0
  24. package/dist/chunk-SIMLMQPT.js +37 -0
  25. package/dist/chunk-SIMLMQPT.js.map +1 -0
  26. package/dist/chunk-U56GQITG.js +79 -0
  27. package/dist/chunk-U56GQITG.js.map +1 -0
  28. package/dist/chunk-U6AXKGYQ.js +101 -0
  29. package/dist/chunk-U6AXKGYQ.js.map +1 -0
  30. package/dist/chunk-VH6FWDWI.js +3 -0
  31. package/dist/chunk-VH6FWDWI.js.map +1 -0
  32. package/dist/chunk-XJTJPBJB.js +191 -0
  33. package/dist/chunk-XJTJPBJB.js.map +1 -0
  34. package/dist/chunk-Z4PA6C2Z.js +3 -0
  35. package/dist/chunk-Z4PA6C2Z.js.map +1 -0
  36. package/dist/client-CqE2dF9A.d.ts +86 -0
  37. package/dist/consent/index.d.ts +59 -0
  38. package/dist/consent/index.js +7 -0
  39. package/dist/consent/index.js.map +1 -0
  40. package/dist/controller-BQb62D0N.d.ts +177 -0
  41. package/dist/dynamic/index.d.ts +235 -0
  42. package/dist/dynamic/index.js +6 -0
  43. package/dist/dynamic/index.js.map +1 -0
  44. package/dist/errors-vJkye4a0.d.ts +110 -0
  45. package/dist/form/index.d.ts +64 -0
  46. package/dist/form/index.js +5 -0
  47. package/dist/form/index.js.map +1 -0
  48. package/dist/index-Dqfa890U.d.ts +83 -0
  49. package/dist/index.d.ts +16 -2
  50. package/dist/index.js +18 -2
  51. package/dist/index.js.map +1 -0
  52. package/dist/model-ESLSEbi0.d.ts +640 -0
  53. package/dist/model-mnjAEtKe.d.ts +253 -0
  54. package/dist/recaptcha/index.d.ts +102 -0
  55. package/dist/recaptcha/index.js +4 -0
  56. package/dist/recaptcha/index.js.map +1 -0
  57. package/dist/render-DC7P2BGQ.js +5 -0
  58. package/dist/render-DC7P2BGQ.js.map +1 -0
  59. package/dist/schema-BrXb9gnh.d.ts +267 -0
  60. package/dist/signup-client-BL5DfarJ.d.ts +392 -0
  61. package/dist/spec/index.d.ts +17 -0
  62. package/dist/spec/index.js +5 -0
  63. package/dist/spec/index.js.map +1 -0
  64. package/dist/submit/index.d.ts +42 -0
  65. package/dist/submit/index.js +6 -0
  66. package/dist/submit/index.js.map +1 -0
  67. package/dist/viewer/index.d.ts +4 -0
  68. package/dist/viewer/index.js +5 -0
  69. package/dist/viewer/index.js.map +1 -0
  70. package/dist/widget/index.d.ts +262 -0
  71. package/dist/widget/index.js +12 -0
  72. package/dist/widget/index.js.map +1 -0
  73. package/package.json +96 -6
package/README.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @nodii/onboarding
2
2
 
3
- Placeholder package at v0.0.1. Real implementation ships at v0.1.0 per the locked feature_doc.
3
+ Browser SDK for the Nodii self-signup onboarding flow (D540 / D543): headless
4
+ form + consent state, reCAPTCHA integration, submit clients, and default
5
+ themeable vanilla-TS widgets.
4
6
 
5
- **Spec**: `https://planning.dev.nucleus-cloud.in/api/v1/feature-docs?serviceId=nodii-libs&docKey=onboarding`
7
+ Subpath exports:
8
+
9
+ - `@nodii/onboarding` — root barrel (most-common items)
10
+ - `@nodii/onboarding/form` — Phase-1 contact-form headless state
11
+ - `@nodii/onboarding/spec` — spec fetch client + D540 model
12
+ - `@nodii/onboarding/dynamic` — spec→Zod derivation + dynamic signup stepper
13
+ - `@nodii/onboarding/consent` — **headless** ConsentPack controller + gates (D543)
14
+ - `@nodii/onboarding/viewer` — **opt-in** default document viewer (DOMPurify /
15
+ marked / PDF.js). Separate subpath so the base bundle stays lean.
16
+ - `@nodii/onboarding/submit` — submit clients + typed error taxonomy
17
+ - `@nodii/onboarding/recaptcha` — reCAPTCHA v3 loader
18
+ - `@nodii/onboarding/widget` — default themeable widgets (contact + signup +
19
+ consent step)
20
+
21
+ ## Tests
22
+
23
+ ### Fast unit + happy-dom (default)
24
+
25
+ ```bash
26
+ bun run test # bun:test — headless controller, submit clients, widgets
27
+ # (happy-dom), spec model, bundle discipline, no-NUL guard
28
+ bun run typecheck # tsc --noEmit (src)
29
+ bun run lint # bun lint + bun fmt check
30
+ ```
31
+
32
+ ### Browser tests (Playwright / real Chromium) — COMPLIANCE AUTHORITY
33
+
34
+ The D543 consent scroll-gate, the PDF.js worker load, and DOMPurify-in-a-real-
35
+ DOM are **layout-dependent** and cannot be validated under happy-dom (no layout
36
+ engine). The Playwright spec in `browser-tests/` is the **authority** for those
37
+ compliance paths — it renders the real SDK bundle in Chromium against genuine
38
+ layout and proves:
39
+
40
+ 1. **scroll gate** — a long doc's gate is CLOSED before scroll and OPENS only
41
+ after a real scroll-to-end (real geometry); `buildConsentPayload` throws
42
+ before / succeeds (with `scrolled_to_end`) after.
43
+ 2. **P0a fabrication guard** — a short doc satisfies only WITH content present;
44
+ an empty/pre-render box never satisfies.
45
+ 3. **PDF worker** — the worker is actually FETCHED + loaded (no pre-set
46
+ `workerSrc`); pages render to `<canvas>`; the scroll gate opens only after a
47
+ real scroll.
48
+ 4. **open_then_accept** — the open affordance satisfies `opened` and only `opened`.
49
+ 5. **DOMPurify** — `<iframe>` / `<script>` / `<object>` are stripped in a real
50
+ browser (closes the "DOMPurify may be inert under bun+happy-dom" gap).
51
+
52
+ ```bash
53
+ # One-time (and in CI): install the Chromium browser binary.
54
+ bunx playwright install chromium
55
+
56
+ # Build the browser bundle + run the Playwright spec.
57
+ bun run test:browser
58
+
59
+ # Typecheck the browser harness + spec.
60
+ bun run typecheck:browser
61
+ ```
62
+
63
+ **CI implication:** `bun run test:browser` requires a Chromium binary, so a CI
64
+ job that runs it MUST add a `bunx playwright install --with-deps chromium` step
65
+ before it (the browser is NOT downloaded by `bun install`). The default
66
+ `ts-ci.yml` `Test` step runs `bun run test` (happy-dom, fast) per package and
67
+ does NOT run the browser spec; wire the browser spec as a dedicated job/step,
68
+ e.g.:
69
+
70
+ ```yaml
71
+ - name: Install Chromium for browser tests
72
+ run: cd ts/onboarding && bunx playwright install --with-deps chromium
73
+ - name: Browser tests (consent compliance authority)
74
+ run: cd ts/onboarding && bun run test:browser
75
+ ```
76
+
77
+ Playwright build artifacts (`browser-tests/.bundle/`, `test-results/`,
78
+ `playwright-report/`) are gitignored.
@@ -0,0 +1,232 @@
1
+ // src/recaptcha/loader.ts
2
+ var RECAPTCHA_SCRIPT_ID = "__nodii_recaptcha_script__";
3
+ var DEFAULT_TIMEOUT_MS = 1e4;
4
+ var POLL_INTERVAL_MS = 50;
5
+ var RecaptchaError = class extends Error {
6
+ kind;
7
+ constructor(kind, message) {
8
+ super(message);
9
+ this.name = "RecaptchaError";
10
+ this.kind = kind;
11
+ }
12
+ };
13
+ var cachedLoad = null;
14
+ var activeSiteKey = null;
15
+ var ownsGlobal = false;
16
+ function removeOwnScriptTag() {
17
+ if (typeof document === "undefined") return;
18
+ const tag = document.getElementById(RECAPTCHA_SCRIPT_ID);
19
+ tag?.parentNode?.removeChild(tag);
20
+ }
21
+ function readyWithTimeout(grecaptcha, timeoutMs, onReady, onTimeout) {
22
+ let done = false;
23
+ const timer = setTimeout(() => {
24
+ if (done) return;
25
+ done = true;
26
+ onTimeout();
27
+ }, timeoutMs);
28
+ grecaptcha.ready(() => {
29
+ if (done) return;
30
+ done = true;
31
+ clearTimeout(timer);
32
+ onReady();
33
+ });
34
+ }
35
+ function loadRecaptcha(siteKey, opts) {
36
+ if (activeSiteKey !== null && activeSiteKey !== siteKey) {
37
+ return Promise.reject(
38
+ new RecaptchaError(
39
+ "sitekey_mismatch",
40
+ "reCAPTCHA is already initialized for a different siteKey. The v3 script supports one siteKey per page; remount with the original key or reload the page."
41
+ )
42
+ );
43
+ }
44
+ if (cachedLoad !== null && cachedLoad.siteKey !== siteKey) {
45
+ return Promise.reject(
46
+ new RecaptchaError(
47
+ "sitekey_mismatch",
48
+ "A reCAPTCHA load for a different siteKey is already in flight."
49
+ )
50
+ );
51
+ }
52
+ if (cachedLoad !== null) return cachedLoad.promise;
53
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ const promise = new Promise((resolve, reject) => {
55
+ let settled = false;
56
+ const settleOk = () => {
57
+ if (settled) return;
58
+ settled = true;
59
+ activeSiteKey = siteKey;
60
+ ownsGlobal = true;
61
+ resolve();
62
+ };
63
+ const settleFail = (err) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ cachedLoad = null;
67
+ removeOwnScriptTag();
68
+ reject(err);
69
+ };
70
+ if (typeof document === "undefined" || typeof window === "undefined") {
71
+ settleFail(
72
+ new RecaptchaError(
73
+ "unavailable",
74
+ "loadRecaptcha: no DOM available (browser-only API)."
75
+ )
76
+ );
77
+ return;
78
+ }
79
+ const ourTagPresent = document.getElementById(RECAPTCHA_SCRIPT_ID) !== null;
80
+ if (window.grecaptcha) {
81
+ if (!ownsGlobal && !ourTagPresent) {
82
+ settleFail(
83
+ new RecaptchaError(
84
+ "sitekey_mismatch",
85
+ "A reCAPTCHA global was pre-loaded by the host page with an unknown site key. The v3 script supports one render key per page and its key cannot be introspected, so executing a different key against it is unsafe. Load reCAPTCHA only via this SDK, or ensure the host uses the same site key."
86
+ )
87
+ );
88
+ return;
89
+ }
90
+ readyWithTimeout(
91
+ window.grecaptcha,
92
+ timeoutMs,
93
+ settleOk,
94
+ () => settleFail(
95
+ new RecaptchaError(
96
+ "timeout",
97
+ `grecaptcha.ready() did not fire within ${timeoutMs}ms (reCAPTCHA runtime blocked).`
98
+ )
99
+ )
100
+ );
101
+ return;
102
+ }
103
+ if (ourTagPresent) {
104
+ const startedAt2 = Date.now();
105
+ const poll = setInterval(() => {
106
+ if (window.grecaptcha) {
107
+ clearInterval(poll);
108
+ const remaining = Math.max(timeoutMs - (Date.now() - startedAt2), 0);
109
+ readyWithTimeout(
110
+ window.grecaptcha,
111
+ remaining,
112
+ settleOk,
113
+ () => settleFail(
114
+ new RecaptchaError(
115
+ "timeout",
116
+ `grecaptcha.ready() did not fire within the ${timeoutMs}ms budget.`
117
+ )
118
+ )
119
+ );
120
+ return;
121
+ }
122
+ if (Date.now() - startedAt2 >= timeoutMs) {
123
+ clearInterval(poll);
124
+ settleFail(
125
+ new RecaptchaError(
126
+ "timeout",
127
+ `reCAPTCHA script tag present but grecaptcha did not appear within ${timeoutMs}ms.`
128
+ )
129
+ );
130
+ }
131
+ }, POLL_INTERVAL_MS);
132
+ return;
133
+ }
134
+ const script = document.createElement("script");
135
+ script.id = RECAPTCHA_SCRIPT_ID;
136
+ script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
137
+ script.async = true;
138
+ script.defer = true;
139
+ const loadTimeout = setTimeout(() => {
140
+ settleFail(
141
+ new RecaptchaError(
142
+ "timeout",
143
+ `reCAPTCHA script did not load within ${timeoutMs}ms.`
144
+ )
145
+ );
146
+ }, timeoutMs);
147
+ const startedAt = Date.now();
148
+ script.onload = () => {
149
+ clearTimeout(loadTimeout);
150
+ if (!window.grecaptcha) {
151
+ settleFail(
152
+ new RecaptchaError(
153
+ "load_failed",
154
+ "reCAPTCHA script loaded but the grecaptcha global was not defined."
155
+ )
156
+ );
157
+ return;
158
+ }
159
+ const remaining = Math.max(timeoutMs - (Date.now() - startedAt), 0);
160
+ readyWithTimeout(
161
+ window.grecaptcha,
162
+ remaining,
163
+ settleOk,
164
+ () => settleFail(
165
+ new RecaptchaError(
166
+ "timeout",
167
+ `reCAPTCHA loaded but grecaptcha.ready() did not fire within ${timeoutMs}ms.`
168
+ )
169
+ )
170
+ );
171
+ };
172
+ script.onerror = () => {
173
+ clearTimeout(loadTimeout);
174
+ settleFail(
175
+ new RecaptchaError(
176
+ "load_failed",
177
+ "Failed to load the reCAPTCHA script (blocked by an ad blocker / CSP, or offline)."
178
+ )
179
+ );
180
+ };
181
+ document.head.appendChild(script);
182
+ });
183
+ cachedLoad = { siteKey, promise };
184
+ return promise;
185
+ }
186
+ async function executeRecaptcha(siteKey, action, opts) {
187
+ await loadRecaptcha(siteKey, opts);
188
+ if (typeof window === "undefined" || !window.grecaptcha) {
189
+ throw new RecaptchaError(
190
+ "unavailable",
191
+ "executeRecaptcha: grecaptcha is not available. Are you running in a browser?"
192
+ );
193
+ }
194
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
195
+ const grecaptcha = window.grecaptcha;
196
+ let timer;
197
+ const timeout = new Promise((_resolve, reject) => {
198
+ timer = setTimeout(() => {
199
+ reject(
200
+ new RecaptchaError(
201
+ "timeout",
202
+ `reCAPTCHA execute() did not settle within ${timeoutMs}ms (runtime blocked).`
203
+ )
204
+ );
205
+ }, timeoutMs);
206
+ });
207
+ try {
208
+ return await Promise.race([
209
+ grecaptcha.execute(siteKey, { action }),
210
+ timeout
211
+ ]);
212
+ } catch (err) {
213
+ if (err instanceof RecaptchaError) throw err;
214
+ const detail = err instanceof Error ? err.message : String(err ?? "unknown error");
215
+ throw new RecaptchaError(
216
+ "execute_failed",
217
+ `reCAPTCHA execute() failed: ${detail}`
218
+ );
219
+ } finally {
220
+ if (timer !== void 0) clearTimeout(timer);
221
+ }
222
+ }
223
+ function _resetRecaptchaState() {
224
+ cachedLoad = null;
225
+ activeSiteKey = null;
226
+ ownsGlobal = false;
227
+ removeOwnScriptTag();
228
+ }
229
+
230
+ export { RecaptchaError, _resetRecaptchaState, executeRecaptcha, loadRecaptcha };
231
+ //# sourceMappingURL=chunk-7NS7ENJV.js.map
232
+ //# sourceMappingURL=chunk-7NS7ENJV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recaptcha/loader.ts"],"names":["startedAt"],"mappings":"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","file":"chunk-7NS7ENJV.js","sourcesContent":["/// <reference lib=\"dom\" />\n/// <reference lib=\"dom.iterable\" />\n/**\n * reCAPTCHA v3 loader and token executor.\n *\n * Per D537:\n * - Site key is public (supplied at mount time from the spec response).\n * - Secret key is NEVER in the browser — only the site key is here.\n * - v3 score-based by default; the operator may configure a v2 fallback\n * on the server side for low-score requests.\n * - The loader injects the reCAPTCHA script once and exposes execute()\n * to obtain a fresh token before each submit.\n *\n * Failure semantics (all recoverable, all bounded by opts.timeoutMs / 10s):\n * - A script load failure (adblock / CSP / offline) REJECTS with\n * RecaptchaError(kind:\"load_failed\"), EVICTS the cached promise, and\n * REMOVES the injected script tag — the next loadRecaptcha call starts\n * fresh. A transient failure never poisons the siteKey.\n * - EVERY wait is bounded, including grecaptcha.ready() AFTER onload and on\n * the adoption/poll paths: if api.js serves but the inner runtime never\n * boots (ready() never fires), the loader rejects RecaptchaError(\n * kind:\"timeout\") and evicts, rather than hanging the widget on\n * \"Sending…\" forever.\n * - The \"existing script tag but no grecaptcha global\" poll is BOUNDED,\n * then the subsequent ready() is bounded by the remaining budget.\n *\n * Multi-siteKey + foreign-global handling (honest):\n * - The v3 script loads with `?render=<siteKey>` and initializes for ONE\n * site key per page. A SECOND different siteKey after our first load is\n * rejected RecaptchaError(kind:\"sitekey_mismatch\").\n * - If the HOST page pre-loaded grecaptcha with an unknown render key (a\n * FOREIGN global this SDK did not create), we CANNOT introspect its key,\n * so executing our key against it would be the exact silent misfire we\n * promise to prevent. We reject RecaptchaError(kind:\"sitekey_mismatch\")\n * instead. (Detection: we only adopt a global we brought up ourselves or\n * that our own script tag is loading.)\n *\n * execute() rejections (invalid key at execute time, Google-internal errors,\n * often plain strings) are wrapped in RecaptchaError(kind:\"execute_failed\").\n *\n * This module is browser-only. It accesses the `window` and `document`\n * globals explicitly.\n */\n\ndeclare global {\n interface Window {\n grecaptcha?: Grecaptcha;\n }\n}\n\ninterface Grecaptcha {\n ready(callback: () => void): void;\n execute(siteKey: string, options: { action: string }): Promise<string>;\n}\n\n/** Exported for tests — the id of the script tag this loader owns. */\nexport const RECAPTCHA_SCRIPT_ID = \"__nodii_recaptcha_script__\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst POLL_INTERVAL_MS = 50;\n\nexport type RecaptchaErrorKind =\n | \"load_failed\"\n | \"timeout\"\n | \"sitekey_mismatch\"\n | \"execute_failed\"\n | \"unavailable\";\n\n/** Typed error for every reCAPTCHA loader failure path. */\nexport class RecaptchaError extends Error {\n public readonly kind: RecaptchaErrorKind;\n\n constructor(kind: RecaptchaErrorKind, message: string) {\n super(message);\n this.name = \"RecaptchaError\";\n this.kind = kind;\n }\n}\n\nexport interface RecaptchaLoadOptions {\n /**\n * Max time to wait for the script / grecaptcha global to become ready\n * before rejecting with RecaptchaError(kind:\"timeout\"). Default 10s.\n */\n timeoutMs?: number;\n}\n\n/** In-flight or settled load promise for the ACTIVE siteKey. */\nlet cachedLoad: { siteKey: string; promise: Promise<void> } | null = null;\n\n/**\n * The siteKey the page's grecaptcha script was initialized with. Set on\n * successful load; cleared only by _resetRecaptchaState. A different siteKey\n * after this is set → RecaptchaError(kind:\"sitekey_mismatch\").\n */\nlet activeSiteKey: string | null = null;\n\n/**\n * True once THIS loader's own <script?render=key> has successfully brought up\n * window.grecaptcha. Distinguishes a global WE initialized (safe to execute\n * against `activeSiteKey`) from a FOREIGN global the host page loaded with an\n * unknown render key (unsafe — we cannot know its key, so we must reject\n * rather than silently execute against it; see P8 in the module doc).\n */\nlet ownsGlobal = false;\n\nfunction removeOwnScriptTag(): void {\n if (typeof document === \"undefined\") return;\n const tag = document.getElementById(RECAPTCHA_SCRIPT_ID);\n tag?.parentNode?.removeChild(tag);\n}\n\n/**\n * Run grecaptcha.ready() with a hard timeout. reCAPTCHA's inner init can be\n * blocked (api.js served but the runtime never boots — corp proxy, partial\n * CSP); ready() would then never fire and the loader would hang forever with\n * the cache un-evicted. This bounds ALL ready() waits and evicts on hang.\n */\nfunction readyWithTimeout(\n grecaptcha: Grecaptcha,\n timeoutMs: number,\n onReady: () => void,\n onTimeout: () => void,\n): void {\n let done = false;\n const timer = setTimeout(() => {\n if (done) return;\n done = true;\n onTimeout();\n }, timeoutMs);\n grecaptcha.ready(() => {\n if (done) return;\n done = true;\n clearTimeout(timer);\n onReady();\n });\n}\n\n/**\n * Inject the reCAPTCHA v3 script for the given siteKey.\n * Idempotent per siteKey: concurrent/repeated calls share one load.\n * A FAILED load is evicted — the next call retries from scratch.\n *\n * @throws {RecaptchaError} kind:\"sitekey_mismatch\" — a different siteKey\n * is already active (single-render-script limitation, see module doc).\n * @throws {RecaptchaError} kind:\"load_failed\" — script failed to load.\n * @throws {RecaptchaError} kind:\"timeout\" — script/global never became ready.\n * @throws {RecaptchaError} kind:\"unavailable\" — no DOM (not a browser).\n */\nexport function loadRecaptcha(\n siteKey: string,\n opts?: RecaptchaLoadOptions,\n): Promise<void> {\n // A different siteKey is already active or loading → hard, typed error.\n if (activeSiteKey !== null && activeSiteKey !== siteKey) {\n return Promise.reject(\n new RecaptchaError(\n \"sitekey_mismatch\",\n \"reCAPTCHA is already initialized for a different siteKey. \" +\n \"The v3 script supports one siteKey per page; remount with the \" +\n \"original key or reload the page.\",\n ),\n );\n }\n if (cachedLoad !== null && cachedLoad.siteKey !== siteKey) {\n return Promise.reject(\n new RecaptchaError(\n \"sitekey_mismatch\",\n \"A reCAPTCHA load for a different siteKey is already in flight.\",\n ),\n );\n }\n\n if (cachedLoad !== null) return cachedLoad.promise;\n\n const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const promise = new Promise<void>((resolve, reject) => {\n let settled = false;\n\n const settleOk = (): void => {\n if (settled) return;\n settled = true;\n activeSiteKey = siteKey;\n ownsGlobal = true;\n resolve();\n };\n\n const settleFail = (err: RecaptchaError): void => {\n if (settled) return;\n settled = true;\n // Evict so the NEXT loadRecaptcha starts fresh — a transient failure\n // must not poison this siteKey for the page's lifetime.\n cachedLoad = null;\n removeOwnScriptTag();\n reject(err);\n };\n\n if (typeof document === \"undefined\" || typeof window === \"undefined\") {\n settleFail(\n new RecaptchaError(\n \"unavailable\",\n \"loadRecaptcha: no DOM available (browser-only API).\",\n ),\n );\n return;\n }\n\n const ourTagPresent = document.getElementById(RECAPTCHA_SCRIPT_ID) !== null;\n\n if (window.grecaptcha) {\n // A grecaptcha global exists. If WE didn't put it there (no prior\n // successful self-load AND no tag of ours in the DOM), it's a FOREIGN\n // global the host page loaded with an unknown ?render= key. We cannot\n // read that key, so executing our siteKey against it would be the exact\n // opaque Google-side misfire we promise to prevent — reject instead.\n if (!ownsGlobal && !ourTagPresent) {\n settleFail(\n new RecaptchaError(\n \"sitekey_mismatch\",\n \"A reCAPTCHA global was pre-loaded by the host page with an \" +\n \"unknown site key. The v3 script supports one render key per \" +\n \"page and its key cannot be introspected, so executing a \" +\n \"different key against it is unsafe. Load reCAPTCHA only via \" +\n \"this SDK, or ensure the host uses the same site key.\",\n ),\n );\n return;\n }\n // It's ours (or our tag is present) — adopt it, bounding ready().\n readyWithTimeout(window.grecaptcha, timeoutMs, settleOk, () =>\n settleFail(\n new RecaptchaError(\n \"timeout\",\n `grecaptcha.ready() did not fire within ${timeoutMs}ms (reCAPTCHA runtime blocked).`,\n ),\n ),\n );\n return;\n }\n\n if (ourTagPresent) {\n // Our tag exists but grecaptcha isn't up yet (still loading, or a stale\n // tag from an earlier failure mode). Poll — BOUNDED — then ALSO bound\n // the subsequent ready() with the remaining budget.\n const startedAt = Date.now();\n const poll = setInterval(() => {\n if (window.grecaptcha) {\n clearInterval(poll);\n const remaining = Math.max(timeoutMs - (Date.now() - startedAt), 0);\n readyWithTimeout(window.grecaptcha, remaining, settleOk, () =>\n settleFail(\n new RecaptchaError(\n \"timeout\",\n `grecaptcha.ready() did not fire within the ${timeoutMs}ms budget.`,\n ),\n ),\n );\n return;\n }\n if (Date.now() - startedAt >= timeoutMs) {\n clearInterval(poll);\n settleFail(\n new RecaptchaError(\n \"timeout\",\n `reCAPTCHA script tag present but grecaptcha did not appear within ${timeoutMs}ms.`,\n ),\n );\n }\n }, POLL_INTERVAL_MS);\n return;\n }\n\n const script = document.createElement(\"script\");\n script.id = RECAPTCHA_SCRIPT_ID;\n script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;\n script.async = true;\n script.defer = true;\n\n const loadTimeout = setTimeout(() => {\n settleFail(\n new RecaptchaError(\n \"timeout\",\n `reCAPTCHA script did not load within ${timeoutMs}ms.`,\n ),\n );\n }, timeoutMs);\n const startedAt = Date.now();\n\n script.onload = () => {\n clearTimeout(loadTimeout);\n if (!window.grecaptcha) {\n settleFail(\n new RecaptchaError(\n \"load_failed\",\n \"reCAPTCHA script loaded but the grecaptcha global was not defined.\",\n ),\n );\n return;\n }\n // Bound ready() with the remaining budget — onload succeeding does NOT\n // guarantee the inner runtime boots.\n const remaining = Math.max(timeoutMs - (Date.now() - startedAt), 0);\n readyWithTimeout(window.grecaptcha, remaining, settleOk, () =>\n settleFail(\n new RecaptchaError(\n \"timeout\",\n `reCAPTCHA loaded but grecaptcha.ready() did not fire within ${timeoutMs}ms.`,\n ),\n ),\n );\n };\n\n script.onerror = () => {\n clearTimeout(loadTimeout);\n settleFail(\n new RecaptchaError(\n \"load_failed\",\n \"Failed to load the reCAPTCHA script (blocked by an ad blocker / CSP, or offline).\",\n ),\n );\n };\n\n document.head.appendChild(script);\n });\n\n cachedLoad = { siteKey, promise };\n return promise;\n}\n\n/**\n * Obtain a fresh reCAPTCHA v3 token for the given action.\n * Loads the script if not already loaded. A previous load FAILURE does not\n * block this call — the loader retries from scratch (see loadRecaptcha).\n *\n * @param siteKey - The public reCAPTCHA site key (from the spec endpoint).\n * @param action - The action name (e.g. \"contact_submit\"). Used for score analytics.\n * @param opts - Optional load options (timeoutMs — also bounds execute()).\n * @returns A fresh reCAPTCHA token. Valid for ~2 minutes.\n *\n * @throws {RecaptchaError} kind:\"timeout\" if execute() does not settle within\n * the timeout (Google runtime hung) — bounded so the widget re-enables.\n * @throws {RecaptchaError} on any load failure (see loadRecaptcha kinds) or\n * an execute() rejection (kind:\"execute_failed\").\n */\nexport async function executeRecaptcha(\n siteKey: string,\n action: string,\n opts?: RecaptchaLoadOptions,\n): Promise<string> {\n await loadRecaptcha(siteKey, opts);\n\n if (typeof window === \"undefined\" || !window.grecaptcha) {\n throw new RecaptchaError(\n \"unavailable\",\n \"executeRecaptcha: grecaptcha is not available. Are you running in a browser?\",\n );\n }\n\n const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n // grecaptcha.execute() can (a) HANG (Google runtime blocked) with no abort\n // API, or (b) reject with a plain string. Bound it with a timeout so the\n // widget never stays on \"Sending…\" forever, and wrap every rejection in a\n // typed RecaptchaError so callers get a consistent taxonomy.\n const grecaptcha = window.grecaptcha;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => {\n reject(\n new RecaptchaError(\n \"timeout\",\n `reCAPTCHA execute() did not settle within ${timeoutMs}ms (runtime blocked).`,\n ),\n );\n }, timeoutMs);\n });\n\n try {\n return await Promise.race([\n grecaptcha.execute(siteKey, { action }),\n timeout,\n ]);\n } catch (err) {\n if (err instanceof RecaptchaError) throw err; // the timeout race\n const detail =\n err instanceof Error ? err.message : String(err ?? \"unknown error\");\n throw new RecaptchaError(\n \"execute_failed\",\n `reCAPTCHA execute() failed: ${detail}`,\n );\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Reset ALL loader state: cached load promise, active siteKey, and the\n * injected script tag (removed from the DOM if present). Primarily for\n * tests and for host pages that intentionally tear down reCAPTCHA.\n * Does NOT unset window.grecaptcha (the script may still own it).\n */\nexport function _resetRecaptchaState(): void {\n cachedLoad = null;\n activeSiteKey = null;\n ownsGlobal = false;\n removeOwnScriptTag();\n}\n"]}
@@ -0,0 +1,186 @@
1
+ import { consentDocKey } from './chunk-U56GQITG.js';
2
+
3
+ // src/consent/controller.ts
4
+ var SCROLL_END_THRESHOLD = 0.99;
5
+ function initialGateSatisfied(level) {
6
+ return level === "checkbox";
7
+ }
8
+ function gateSatisfiedFor(d) {
9
+ switch (d.doc.acceptanceLevel) {
10
+ case "checkbox":
11
+ return true;
12
+ case "scroll_then_accept":
13
+ return d.scrolledToEnd;
14
+ case "open_then_accept":
15
+ return d.opened;
16
+ default:
17
+ return false;
18
+ }
19
+ }
20
+ function evidenceOf(d) {
21
+ switch (d.doc.acceptanceLevel) {
22
+ case "scroll_then_accept":
23
+ return { scrolled_to_end: d.scrolledToEnd };
24
+ case "open_then_accept":
25
+ return { opened: d.opened };
26
+ default:
27
+ return {};
28
+ }
29
+ }
30
+ function createConsentController(pack) {
31
+ let docs = (pack?.documents ?? []).map((doc) => ({
32
+ doc,
33
+ key: consentDocKey(doc),
34
+ accepted: false,
35
+ scrolledToEnd: false,
36
+ scrollProgress: 0,
37
+ opened: false
38
+ }));
39
+ const listeners = /* @__PURE__ */ new Set();
40
+ function find(key) {
41
+ return docs.find((d) => d.key === key);
42
+ }
43
+ function snapshot() {
44
+ const documents = docs.map((d) => ({
45
+ doc: d.doc,
46
+ key: d.key,
47
+ acceptanceLevel: d.doc.acceptanceLevel,
48
+ required: d.doc.required,
49
+ gateSatisfied: gateSatisfiedFor(d),
50
+ accepted: d.accepted,
51
+ evidence: evidenceOf(d),
52
+ scrollProgress: d.scrollProgress
53
+ }));
54
+ return { documents, complete: computeComplete() };
55
+ }
56
+ function computeComplete() {
57
+ for (const d of docs) {
58
+ if (d.doc.required && !(d.accepted && gateSatisfiedFor(d))) return false;
59
+ }
60
+ return true;
61
+ }
62
+ function emit() {
63
+ const state = snapshot();
64
+ for (const listener of listeners) listener(state);
65
+ }
66
+ return {
67
+ getState() {
68
+ return snapshot();
69
+ },
70
+ isEmpty() {
71
+ return docs.length === 0;
72
+ },
73
+ isComplete() {
74
+ return computeComplete();
75
+ },
76
+ canSubmit() {
77
+ return computeComplete();
78
+ },
79
+ isGateSatisfied(key) {
80
+ const d = find(key);
81
+ return d ? gateSatisfiedFor(d) : false;
82
+ },
83
+ reportScrollProgress(key, ratio) {
84
+ const d = find(key);
85
+ if (!d || d.doc.acceptanceLevel !== "scroll_then_accept") return;
86
+ const clamped = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
87
+ const reachedEnd = clamped >= SCROLL_END_THRESHOLD;
88
+ const nextProgress = Math.max(d.scrollProgress, clamped);
89
+ const nextScrolledToEnd = d.scrolledToEnd || reachedEnd;
90
+ if (nextProgress === d.scrollProgress && nextScrolledToEnd === d.scrolledToEnd) {
91
+ return;
92
+ }
93
+ d.scrollProgress = nextProgress;
94
+ d.scrolledToEnd = nextScrolledToEnd;
95
+ emit();
96
+ },
97
+ markScrolledToEnd(key) {
98
+ const d = find(key);
99
+ if (!d || d.doc.acceptanceLevel !== "scroll_then_accept") return;
100
+ if (d.scrolledToEnd && d.scrollProgress >= 1) return;
101
+ d.scrolledToEnd = true;
102
+ d.scrollProgress = 1;
103
+ emit();
104
+ },
105
+ markOpened(key) {
106
+ const d = find(key);
107
+ if (!d || d.doc.acceptanceLevel !== "open_then_accept") return;
108
+ if (d.opened) return;
109
+ d.opened = true;
110
+ emit();
111
+ },
112
+ setAccepted(key, accepted) {
113
+ const d = find(key);
114
+ if (!d) return;
115
+ if (accepted) {
116
+ if (!gateSatisfiedFor(d)) return;
117
+ if (d.accepted) return;
118
+ d.accepted = true;
119
+ } else {
120
+ if (!d.accepted) return;
121
+ d.accepted = false;
122
+ }
123
+ emit();
124
+ },
125
+ buildConsentPayload() {
126
+ const out = [];
127
+ for (const d of docs) {
128
+ const gate = gateSatisfiedFor(d);
129
+ if (d.doc.required && !(d.accepted && gate)) {
130
+ throw new Error(
131
+ `buildConsentPayload: cannot build a consent payload \u2014 required document "${d.key}" (${d.doc.acceptanceLevel}) is not accepted at its gate. Never fabricate acceptance; surface the consent gate to the user first.`
132
+ );
133
+ }
134
+ if (d.accepted) {
135
+ out.push({
136
+ legalDocumentId: d.doc.legalDocumentId,
137
+ versionNo: d.doc.versionNo,
138
+ accepted: true,
139
+ evidence: evidenceOf(d)
140
+ });
141
+ }
142
+ }
143
+ return out;
144
+ },
145
+ applyNewPack(newPack) {
146
+ const prevByKey = new Map(docs.map((d) => [d.key, d]));
147
+ const resetKeys = [];
148
+ docs = (newPack?.documents ?? []).map((doc) => {
149
+ const key = consentDocKey(doc);
150
+ const prev = prevByKey.get(key);
151
+ const unchanged = prev !== void 0 && prev.doc.versionNo === doc.versionNo && prev.doc.contentHash === doc.contentHash && prev.doc.acceptanceLevel === doc.acceptanceLevel && prev.doc.required === doc.required;
152
+ if (unchanged) {
153
+ return {
154
+ doc,
155
+ key,
156
+ accepted: prev.accepted,
157
+ scrolledToEnd: prev.scrolledToEnd,
158
+ scrollProgress: prev.scrollProgress,
159
+ opened: prev.opened
160
+ };
161
+ }
162
+ if (prev?.accepted) resetKeys.push(key);
163
+ return {
164
+ doc,
165
+ key,
166
+ accepted: false,
167
+ scrolledToEnd: false,
168
+ scrollProgress: 0,
169
+ opened: false
170
+ };
171
+ });
172
+ emit();
173
+ return resetKeys;
174
+ },
175
+ subscribe(listener) {
176
+ listeners.add(listener);
177
+ return () => {
178
+ listeners.delete(listener);
179
+ };
180
+ }
181
+ };
182
+ }
183
+
184
+ export { SCROLL_END_THRESHOLD, createConsentController, initialGateSatisfied };
185
+ //# sourceMappingURL=chunk-AZE3NPSY.js.map
186
+ //# sourceMappingURL=chunk-AZE3NPSY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/consent/controller.ts"],"names":[],"mappings":"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","file":"chunk-AZE3NPSY.js","sourcesContent":["/**\n * Headless ConsentPack controller (D543 §2/§5).\n *\n * Framework-agnostic consent state for the signup flow's consent stage.\n * Exposes, per document: { doc, accepted, gateSatisfied, evidence } and\n * enforces the per-document ACCEPTANCE-LEVEL GATE (D543 §2):\n *\n * - checkbox — accept is enable-able immediately (basic clickwrap).\n * - scroll_then_accept — accept is DISABLED until the rendered doc is\n * scrolled to the end (evidence.scrolled_to_end).\n * - open_then_accept — accept is DISABLED until the doc is opened/viewed\n * (evidence.opened).\n *\n * The gate is a UX guard (D543 §5: \"Client evidence is UX-only; the server\n * re-validates\"). But the controller ENFORCES it locally so a UI cannot\n * present an accept affordance the gate hasn't unlocked, and so a headless\n * caller cannot assemble a payload that claims an unsatisfied gate.\n *\n * COMPLIANCE-CRITICAL (same discipline as the stepper's consent-fabrication\n * fix): `accept(key)` is REFUSED when the gate is not yet satisfied, and\n * `buildConsentPayload()` THROWS if ANY required doc is not accepted at its\n * gate. The controller NEVER fabricates acceptance or evidence — it only\n * records what the user genuinely did. `isComplete()` is true only when every\n * REQUIRED doc is accepted with its gate satisfied.\n *\n * Same subscribe/mutate contract as the dynamic stepper so the default\n * component layer renders it and tenants can build their own UI against the\n * exported control surface (D543 §5 headless flexibility).\n */\n\nimport {\n consentDocKey,\n type AcceptanceLevel,\n type ConsentDocument,\n type ConsentPack,\n} from \"./model.js\";\n\n/**\n * Evidence for a document's gate. Only the field relevant to the doc's\n * acceptanceLevel is ever set true — a checkbox doc carries no evidence.\n * Mirrors the D543 §4 submit `evidence:{scrolled_to_end?, opened?}` — these\n * two keys stay snake_case (the ONLY snake bits in the consent wire shape).\n */\nexport interface ConsentEvidence {\n scrolled_to_end?: boolean;\n opened?: boolean;\n}\n\n/**\n * Per-document view of the consent state — the framework-agnostic \"row model\"\n * a consumer binds their own rendering to (TanStack-style). NO DOM here.\n */\nexport interface ConsentDocState {\n /** The document descriptor (from the spec's consentPack). */\n doc: ConsentDocument;\n /** Stable client-side key (docType | custom:<customKey>). */\n key: string;\n /** The document's acceptance level, surfaced for convenience. */\n acceptanceLevel: AcceptanceLevel;\n /** Whether the document is required, surfaced for convenience. */\n required: boolean;\n /**\n * Whether the acceptance-level GATE is satisfied — i.e. accept is\n * enable-able. checkbox ⇒ always true; scroll ⇒ scrolled-to-end;\n * open ⇒ opened.\n */\n gateSatisfied: boolean;\n /** Whether the user has genuinely accepted (only possible past the gate). */\n accepted: boolean;\n /** The evidence collected so far (drives gateSatisfied for gated levels). */\n evidence: ConsentEvidence;\n /**\n * Scroll progress in [0,1] for a `scroll_then_accept` doc (0 for other\n * levels). A consumer's scroll handler feeds this via reportScrollProgress;\n * reaching ~1 flips gateSatisfied. Purely a UX-progress read (e.g. a\n * progress bar); the gate itself keys on scrolledToEnd evidence.\n */\n scrollProgress: number;\n}\n\nexport interface ConsentState {\n /** All documents in spec order. */\n documents: ConsentDocState[];\n /** True iff every REQUIRED doc is accepted with its gate satisfied. */\n complete: boolean;\n}\n\nexport type ConsentStateListener = (state: ConsentState) => void;\n\n/**\n * One element of the D543 §4 submit `consent.documents` array — CANONICAL\n * camelCase (legalDocumentId, versionNo, accepted); `evidence` keys stay\n * snake_case (scrolled_to_end / opened).\n */\nexport interface ConsentSubmitDocument {\n legalDocumentId: string;\n versionNo: number;\n accepted: true;\n evidence: ConsentEvidence;\n}\n\nexport interface ConsentController {\n getState(): ConsentState;\n /** True iff there are no documents to consent to (stage is skippable). */\n isEmpty(): boolean;\n /** True iff every required doc is accepted with its gate satisfied. */\n isComplete(): boolean;\n /**\n * Alias of isComplete() — TanStack-style ergonomic read: \"can the consumer\n * submit the consent step?\" (all required docs accepted at their gate).\n */\n canSubmit(): boolean;\n /**\n * Whether the given document's gate is satisfied (accept is enable-able).\n * Unknown key ⇒ false.\n */\n isGateSatisfied(key: string): boolean;\n /**\n * Report scroll progress in [0,1] for a `scroll_then_accept` doc — the\n * consumer's OWN scroll handler feeds this (e.g. on its same-origin scroll\n * container). Ratio ≥ SCROLL_END_THRESHOLD marks scrolled-to-end (opens the\n * gate); otherwise it just updates scrollProgress for UX. Clamped to [0,1].\n * No-op for other levels and unknown keys.\n */\n reportScrollProgress(key: string, ratio: number): void;\n /**\n * Mark a `scroll_then_accept` doc as scrolled to the end (evidence). No-op\n * for other levels (their gate isn't scroll-driven) and unknown keys.\n * Equivalent to reportScrollProgress(key, 1).\n */\n markScrolledToEnd(key: string): void;\n /**\n * Mark an `open_then_accept` doc as opened/viewed (evidence). No-op for\n * other levels and unknown keys.\n */\n markOpened(key: string): void;\n /**\n * Set a document's accepted flag. Accepting is REFUSED (accepted stays\n * false) when the gate is not satisfied — a UI cannot record acceptance a\n * user could not legitimately give. Unaccepting is always allowed.\n */\n setAccepted(key: string, accepted: boolean): void;\n /**\n * Build the D543 §4 submit array from the ACTUAL consent state.\n *\n * @throws {Error} if any REQUIRED doc is not accepted with its gate\n * satisfied — never fabricates acceptance/evidence (compliance-critical).\n * Optional docs that were accepted ARE included; optional docs left\n * unaccepted are omitted.\n */\n buildConsentPayload(): ConsentSubmitDocument[];\n /**\n * Swap in a refetched consentPack after 409 consent_stale. For every doc\n * whose (versionNo, contentHash) CHANGED, acceptance + evidence are\n * RESET (re-acceptance required — NEVER auto-accept a changed doc, D543 §4\n * / task §4). Unchanged docs keep their state. Returns the keys whose\n * consent was reset so the UI can highlight what changed.\n */\n applyNewPack(pack: ConsentPack | undefined): string[];\n subscribe(listener: ConsentStateListener): () => void;\n}\n\n/**\n * Scroll ratio at/above which a `scroll_then_accept` doc counts as\n * scrolled-to-end. Slightly below 1 so sub-pixel rounding on the consumer's\n * scroll math (scrollTop+clientHeight vs scrollHeight) still opens the gate.\n */\nexport const SCROLL_END_THRESHOLD = 0.99;\n\n/** checkbox docs have no gate; gated levels start unsatisfied. */\nfunction initialGateSatisfied(level: AcceptanceLevel): boolean {\n return level === \"checkbox\";\n}\n\ninterface InternalDoc {\n doc: ConsentDocument;\n key: string;\n accepted: boolean;\n scrolledToEnd: boolean;\n scrollProgress: number;\n opened: boolean;\n}\n\nfunction gateSatisfiedFor(d: InternalDoc): boolean {\n switch (d.doc.acceptanceLevel) {\n case \"checkbox\":\n return true;\n case \"scroll_then_accept\":\n return d.scrolledToEnd;\n case \"open_then_accept\":\n return d.opened;\n default:\n // Unreachable — the level enum is exhaustive and validated at parse.\n // Fail CLOSED: an unknown level is never auto-satisfied.\n return false;\n }\n}\n\nfunction evidenceOf(d: InternalDoc): ConsentEvidence {\n switch (d.doc.acceptanceLevel) {\n case \"scroll_then_accept\":\n return { scrolled_to_end: d.scrolledToEnd };\n case \"open_then_accept\":\n return { opened: d.opened };\n default:\n return {};\n }\n}\n\n/**\n * Create a headless consent controller for a spec's consentPack.\n *\n * @param pack the spec's `consentPack` (or undefined ⇒ an empty controller;\n * the stage is skipped and `isComplete()` / `isEmpty()` are true).\n *\n * @example\n * ```ts\n * const consent = createConsentController(spec.consentPack);\n * consent.subscribe(render);\n * consent.markScrolledToEnd(\"tos\"); // scroll_then_accept gate\n * consent.setAccepted(\"tos\", true); // now allowed\n * const docs = consent.buildConsentPayload(); // throws if any required unmet\n * ```\n */\nexport function createConsentController(\n pack: ConsentPack | undefined,\n): ConsentController {\n let docs: InternalDoc[] = (pack?.documents ?? []).map((doc) => ({\n doc,\n key: consentDocKey(doc),\n accepted: false,\n scrolledToEnd: false,\n scrollProgress: 0,\n opened: false,\n }));\n\n const listeners = new Set<ConsentStateListener>();\n\n function find(key: string): InternalDoc | undefined {\n return docs.find((d) => d.key === key);\n }\n\n function snapshot(): ConsentState {\n const documents: ConsentDocState[] = docs.map((d) => ({\n doc: d.doc,\n key: d.key,\n acceptanceLevel: d.doc.acceptanceLevel,\n required: d.doc.required,\n gateSatisfied: gateSatisfiedFor(d),\n accepted: d.accepted,\n evidence: evidenceOf(d),\n scrollProgress: d.scrollProgress,\n }));\n return { documents, complete: computeComplete() };\n }\n\n function computeComplete(): boolean {\n for (const d of docs) {\n if (d.doc.required && !(d.accepted && gateSatisfiedFor(d))) return false;\n }\n return true;\n }\n\n function emit(): void {\n const state = snapshot();\n for (const listener of listeners) listener(state);\n }\n\n return {\n getState() {\n return snapshot();\n },\n\n isEmpty() {\n return docs.length === 0;\n },\n\n isComplete() {\n return computeComplete();\n },\n\n canSubmit() {\n return computeComplete();\n },\n\n isGateSatisfied(key) {\n const d = find(key);\n return d ? gateSatisfiedFor(d) : false;\n },\n\n reportScrollProgress(key, ratio) {\n const d = find(key);\n if (!d || d.doc.acceptanceLevel !== \"scroll_then_accept\") return;\n const clamped = Number.isFinite(ratio)\n ? Math.min(1, Math.max(0, ratio))\n : 0;\n const reachedEnd = clamped >= SCROLL_END_THRESHOLD;\n // Progress never regresses below a prior high-water mark that already\n // opened the gate — once scrolled-to-end, a scroll-back must not re-lock\n // acceptance (matches how clickwrap gates latch).\n const nextProgress = Math.max(d.scrollProgress, clamped);\n const nextScrolledToEnd = d.scrolledToEnd || reachedEnd;\n if (\n nextProgress === d.scrollProgress &&\n nextScrolledToEnd === d.scrolledToEnd\n ) {\n return;\n }\n d.scrollProgress = nextProgress;\n d.scrolledToEnd = nextScrolledToEnd;\n emit();\n },\n\n markScrolledToEnd(key) {\n const d = find(key);\n if (!d || d.doc.acceptanceLevel !== \"scroll_then_accept\") return;\n if (d.scrolledToEnd && d.scrollProgress >= 1) return;\n d.scrolledToEnd = true;\n d.scrollProgress = 1;\n emit();\n },\n\n markOpened(key) {\n const d = find(key);\n if (!d || d.doc.acceptanceLevel !== \"open_then_accept\") return;\n if (d.opened) return;\n d.opened = true;\n emit();\n },\n\n setAccepted(key, accepted) {\n const d = find(key);\n if (!d) return;\n if (accepted) {\n // COMPLIANCE: refuse to record acceptance a user could not give —\n // the gate must be satisfied first. A UI that offers accept before\n // the gate is unlocked cannot fabricate consent through this control.\n if (!gateSatisfiedFor(d)) return;\n if (d.accepted) return;\n d.accepted = true;\n } else {\n if (!d.accepted) return;\n d.accepted = false;\n }\n emit();\n },\n\n buildConsentPayload() {\n const out: ConsentSubmitDocument[] = [];\n for (const d of docs) {\n const gate = gateSatisfiedFor(d);\n if (d.doc.required && !(d.accepted && gate)) {\n throw new Error(\n `buildConsentPayload: cannot build a consent payload — required document \"${d.key}\" (${d.doc.acceptanceLevel}) is not accepted at its gate. Never fabricate acceptance; surface the consent gate to the user first.`,\n );\n }\n // Include every ACCEPTED doc (required or optional). An accepted doc\n // always has its gate satisfied (setAccepted refuses otherwise), so\n // its evidence is trustworthy.\n if (d.accepted) {\n out.push({\n legalDocumentId: d.doc.legalDocumentId,\n versionNo: d.doc.versionNo,\n accepted: true,\n evidence: evidenceOf(d),\n });\n }\n }\n return out;\n },\n\n applyNewPack(newPack) {\n const prevByKey = new Map(docs.map((d) => [d.key, d]));\n const resetKeys: string[] = [];\n\n docs = (newPack?.documents ?? []).map((doc) => {\n const key = consentDocKey(doc);\n const prev = prevByKey.get(key);\n // A doc is UNCHANGED iff the same key AND the same live version AND the\n // same content hash AND the same GATE SHAPE (acceptanceLevel + required).\n // Only then may prior acceptance carry over. A gate ESCALATION at the\n // same version/hash (e.g. checkbox → scroll_then_accept, or required\n // flipping true) would otherwise keep accepted:true with an unsatisfied\n // gate — violating the \"an accepted doc always has its gate satisfied\"\n // invariant and making buildConsentPayload emit accepted:true with\n // insufficient evidence → an unrecoverable server-side gate rejection\n // (P2). Treat any gate-shape change as \"changed\" → reset + re-lock.\n const unchanged =\n prev !== undefined &&\n prev.doc.versionNo === doc.versionNo &&\n prev.doc.contentHash === doc.contentHash &&\n prev.doc.acceptanceLevel === doc.acceptanceLevel &&\n prev.doc.required === doc.required;\n if (unchanged) {\n return {\n doc,\n key,\n accepted: prev.accepted,\n scrolledToEnd: prev.scrolledToEnd,\n scrollProgress: prev.scrollProgress,\n opened: prev.opened,\n };\n }\n // Changed (or new) doc → require fresh acceptance. NEVER auto-accept.\n if (prev?.accepted) resetKeys.push(key);\n return {\n doc,\n key,\n accepted: false,\n scrolledToEnd: false,\n scrollProgress: 0,\n opened: false,\n };\n });\n\n emit();\n return resetKeys;\n },\n\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/** Re-export the gate-init helper for the widget's initial affordance state. */\nexport { initialGateSatisfied };\n"]}