From 55adb797051473c3067a0d454515153237e9b8d6 Mon Sep 17 00:00:00 2001 From: Jeffrey Hales Date: Fri, 30 Oct 2026 14:21:27 -0700 Subject: [PATCH] fix(forms): close completed drawers and restore portal styling --- www/integrations/employee-portal/README.md | 18 ++ www/reports/SESSION_HANDOFF.md | 35 ++- www/reports/migration-summary.md | 17 ++ ...-9-drawer-portal-corrections-2026-10-30.md | 228 ++++++++++++++++++ ...se-9-form-a-owner-acceptance-2026-10-28.md | 25 ++ ...rm-integration-specification-2026-10-28.md | 20 ++ www/src/components/JotformIframe.astro | 15 ++ www/src/components/ServiceInquiryDrawer.astro | 3 + www/src/components/pages/EmployeePortal.astro | 35 ++- www/src/scripts/employee-portal.ts | 13 + www/src/scripts/service-inquiry.ts | 28 ++- www/src/utils/jotform-completion.ts | 18 ++ www/src/utils/portal-presentation.ts | 9 + www/tools/service-portal-browser.mjs | 113 ++++++++- www/tools/service-portal.test.mjs | 30 +++ 15 files changed, 598 insertions(+), 9 deletions(-) create mode 100644 www/reports/phase-9-drawer-portal-corrections-2026-10-30.md create mode 100644 www/src/utils/jotform-completion.ts create mode 100644 www/src/utils/portal-presentation.ts diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md index 7569030..410e26a 100644 --- a/www/integrations/employee-portal/README.md +++ b/www/integrations/employee-portal/README.md @@ -93,6 +93,24 @@ has not been decrypted or reviewed; any removed behavior must be reviewed with the owner before claiming fidelity. It must not silently become arbitrary script execution. No real encrypted stream or plaintext is in Git. +### Scoped presentation correction, 2026-10-30 + +The renderer now maps only `hs-button`, `content-button`, or +`portal-resource-button` anchor tokens, or exact inline AIA orange background +colors (`#ffbd59` / `#ff914d`), to the local `portal-resource-button` class. +It does not copy the original style or arbitrary classes. These are narrow +compatibility candidates, not a claim that encrypted real views have been +inspected. HTTPS/mailto/tel links retain the existing safe URL checks; contact +links receive a local dark-orange accent for readable contrast. Table scope +and bounded numeric row/column spans are preserved. All other executable or +unapproved attributes remain removed. + +Scoped typography, spacing, lists, table rules and keyboard focus apply to both +unlocked streams. Synthetic staff/management fixtures exercise these mappings +at 320/390/768/1440px. Owner must inspect the real views privately to confirm +legacy button recognition and source fidelity; if a different class is used, +provide sanitized class semantics rather than decrypted content or credentials. + Before owner acceptance: validate two real view codes privately, inspect every resource/category, responsive directories, focus, link semantics and sanitization impact. Check wrong-code feedback, keyboard unlock, no-JS explanation and refresh. diff --git a/www/reports/SESSION_HANDOFF.md b/www/reports/SESSION_HANDOFF.md index faa005e..d568610 100644 --- a/www/reports/SESSION_HANDOFF.md +++ b/www/reports/SESSION_HANDOFF.md @@ -3,9 +3,9 @@ **Last updated:** October 30, 2026 (UTC) **Repository:** `AIAMarketing/azinstitute4autism.com` **Working branch:** `faithful-astro-migration` -**Last accepted technical implementation:** Phase 9 isolated consultation integration at `ff80ee516f0185023af1f29937e29dfb30d31f47`; this handoff also describes the consolidated service-drawer and employee-portal checkpoint candidate +**Last accepted technical implementation:** consolidated service drawers and employee-portal preview at `e97f0ea9b475715a1ea58110a27cdf50a2b3cb19`; the commit containing this handoff adds the focused completion/styling correction pass **Previous accepted checkpoint:** P9-03 documentation, signed `537f9864a0a3d0806180cbeca909bf1d03200c6f` -**Implementation checkpoint:** the commit containing this handoff update; obtain its actual SHA from Git history after the user creates the signed commit. +**Implementation checkpoint:** the commit containing this handoff update; obtain its actual SHA from Git history after the signed checkpoint is created. **Next milestone:** continue Phase 9 owner decisions and authorized integration batches; then Phase 10 — Final QA/Cutover Readiness. The landing-page migration is technically completed for checkpoint purposes. @@ -401,3 +401,34 @@ staff/management authorization. See and [owner utility instructions](../integrations/employee-portal/README.md). The actual checkpoint SHA is the commit containing this section; obtain it from Git history. No push, deployment or cutover is authorized by this work. + +## Focused completion, portal styling and Spanish diagnostic — 2026-10-30 + +Authorized consultation drawers now accept only the documented +`submission-completed` / `formID` envelope from their activated iframe window +at the exact configured Jotform origin while the drawer is open. Object and +bounded JSON-string envelopes are supported; other actions/messages are ignored. +Completion writes the existing global 30-day expiry immediately, reveals EN/ES +acknowledgment, then starts slide-out after five seconds. Manual dismissal or +navigation cancels the countdown. Duplicate completion cannot restart it. +The next deliberate opening replaces the completed iframe; incomplete forms +remain intact across ordinary close/reopen. Load remains fallback-only, never +submission evidence. Real provider-message acceptance requires one owner-run +synthetic submission; automated tests use intercepted local fixtures only. + +Portal presentation now restores scoped blue headings, spacing, directories, +orange report buttons and accessible contact accents without retaining arbitrary +classes, styles or scripts. Two synthetic encrypted views are verified. Private +source exposed no plaintext class/CSS semantics, and real view codes were not +available; exact legacy button recognition and visual parity require owner review. +No private content/resource or ciphertext was added to Git or public output. + +The reported seven-option Spanish consultation dropdown is an unresolved +Jotform configuration issue: generated EN/ES URLs have identical form ID and +`formContext=consultation`, differing only in `language`. Astro neither supplies +nor edits child dropdown options. Owner should compare the direct hosted Spanish +form and review multilingual option translations/Update Options conditions. +No account changes or real submissions were performed. Seven production and +nine isolated-test placements remain unchanged; production consultation, portal +cutover and site cutover remain unauthorized. See the +[focused correction evidence](phase-9-drawer-portal-corrections-2026-10-30.md). diff --git a/www/reports/migration-summary.md b/www/reports/migration-summary.md index 1255072..5ff93da 100644 --- a/www/reports/migration-summary.md +++ b/www/reports/migration-summary.md @@ -4350,3 +4350,20 @@ owner input. A passive DOM renderer intentionally disallows executable markup, remote media and embedded forms; real-view fidelity therefore needs owner review. No internal resource was published. Full evidence and boundaries are in [the consolidated report](phase-9-service-portal-consolidated-2026-10-30.md). + +### Focused correction addendum, 2026-10-30 + +Consultation drawers now acknowledge strictly verified provider completion for +five seconds before the existing slide-out, establish the shared 30-day +suppression immediately, and offer a fresh iframe on subsequent manual opening. +This owner-authorized behavior is isolated-test-only for the nine consultation +placements. Provider acceptance is pending; no real submission was generated. + +Portal typography, spacing, tables and narrow safe button mappings restore AIA +visual hierarchy in synthetic encrypted views. The contact-link orange is +darkened to `#a94600` for text contrast on white; buttons preserve existing +orange/blue design tokens. Actual unlocked source parity remains owner review +because credentials were unavailable. No private markup or resources are tracked. +The Spanish extra dropdown choices remain a Jotform Builder review item, with +fixed Astro context/locale URLs verified. See +[focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md). diff --git a/www/reports/phase-9-drawer-portal-corrections-2026-10-30.md b/www/reports/phase-9-drawer-portal-corrections-2026-10-30.md new file mode 100644 index 0000000..0b90e99 --- /dev/null +++ b/www/reports/phase-9-drawer-portal-corrections-2026-10-30.md @@ -0,0 +1,228 @@ +# Focused consultation completion and portal presentation correction + +Date: 2026-10-30 UTC. Starting branch `faithful-astro-migration`, starting HEAD +`e97f0ea9b475715a1ea58110a27cdf50a2b3cb19`. Tracked tree and index were clean; +no untracked paths were reported at preflight. The checkpoint is the commit +containing this report; no push, deployment, Jotform mutation or real submission +is part of this correction. + +## Completion contract and implementation + +[Jotform Support](https://www.jotform.com/answers/30198031-detect-succesfull-submission) +and its linked screenshot were inspected. The screenshot shows an object +containing `action: 'submission-completed'` and string `formID` emitted by the +thank-you document. It is provider documentation, not a captured submission +from AIA Form A. The earlier automated support response on the same thread +does not describe this contract; the later human support reply does. + +The receiver is installed only for authorized consultation drawers. It requires: + +- Open, non-closing drawer with an activated consultation iframe. +- `event.source` equal to that iframe's actual `contentWindow`. +- Exact origin equal to the trusted server-rendered URL's origin: + `https://form.jotform.com`. No suffix, substring or wildcard match. +- Exact action and string Form A ID `262946078314059` matching that fixed URL. +- No prior completion for this frame instance. + +Object envelopes and JSON strings of at most 4096 characters are supported. +Malformed, numeric-ID, unrelated and untrusted envelopes are rejected. Extra +fields are neither collected nor logged. The receiver uses the existing gated +URL instead of embedding a production ID/origin in a shared off-mode bundle. +No cross-origin DOM inspection, message forwarding, tracking or submission +handler was introduced. The official resize helper remains absent. +This trusted-window UI signal is not an independent server-side receipt audit. + +On acceptance, the current global key +`aia:service-inquiry:auto-suppressed-until` immediately receives a 30-day expiry. +The drawer reveals “Thank you! Your message has been received.” or +“¡Gracias! Hemos recibido su mensaje.” above the iframe and scrolls it into view. +The existing thank-you document remains untouched. The full 5000ms interval +precedes the existing 500ms slide-out; reduced motion removes only the animation. +Focus then returns through the existing close mechanism. + +Duplicates do not extend the timer or expiry. Manual close cancels the countdown +and retains suppression; navigation removes listeners/timers. Completed state +survives lifecycle reinitialization. A subsequent deliberate CTA replaces the +completed frame with a fresh node, clears only its fallback/load state, and +activates the same fixed URL. Old WindowProxy messages then fail source checks. +Incomplete frames remain intact across ordinary close/reopen. Load, validation, +clicks and timeout never count as completion. + +The four-second/half-scroll opening rules, manual override, shared suppression, +bilingual text, no-JS fallback, sandbox, no-referrer and seven-production versus +nine-consultation-test route boundaries are unchanged. + +## Portal presentation and evidence limits + +Both streams use the same scoped presentation: blue section/subsection headings, +vertical spacing, list indentation, directories, orange action buttons and +visible focus. Only a narrow compatibility vocabulary maps to a local button +class: `hs-button`, `content-button`, `portal-resource-button`, or an exact +inline AIA orange background (`#ffbd59`/`#ff914d`). Styles themselves are never +copied. Contact destinations receive a dark-orange `#a94600` underline treatment +for readable contrast against white; buttons retain AIA orange/blue tokens. +Table `scope` and numeric spans 1–99 preserve directory semantics. + +Arbitrary classes, inline style/event handlers, scripts, forms, remote styles, +iframes/media and unsafe URLs remain removed. Crypto, access-code handling, +ciphertext, resource mapping and publication gates are unchanged. + +The legitimate private footer was checked only for unencrypted stylesheet/class +semantics; none were available. No real code was supplied and no actual stream +was decrypted. Button tokens are narrow compatibility candidates, not verified +real-view class inventory. Synthetic staff and management encrypted fixtures +exercise both class-based and exact brand-fill mappings. Source-faithful +restoration in the actual two views needs owner comparison; do not claim +pixel parity or expose decrypted HTML to obtain that evidence. No employee +resources, credentials, original HTML or real ciphertext were put in Git, +screenshots or public static assets. + +## Spanish dropdown diagnosis + +The owner observed five English service options and seven Spanish options, +including `Pregunta general` and `Programar una visita guiada`. Generated URLs: + +| Locale | Exact consultation URL | +|---|---| +| EN | `https://form.jotform.com/262946078314059?formContext=consultation&language=en` | +| ES | `https://form.jotform.com/262946078314059?formContext=consultation&language=es` | + +The three standalone and six drawer mappings use the same fixed form/context; +only the locale differs. The parent does not create an extra intent question, +preselect services, alter iframe fields or forward page queries. In test mode +legacy drawer controls are absent from the active collection surface. + +Likely boundary: Jotform multilingual option translations or Update Options / +show-hide conditions retaining inquiry choices in Spanish. This is an inference +from source and owner observations, not a Builder audit. Owner should open the +direct hosted ES consultation URL and compare with EN and the embedded form. +If the direct ES version also shows seven choices, review translations of the +correct service question and every condition that changes its options. Preserve +contact/landing behavior and verify exactly five consultation choices in both +languages. No Jotform configuration or client-side workaround was attempted. + +## Verification + +- Focused unit/isolation/artifact suites: 31 passing tests (including two new + tests for completion-envelope validation and narrow portal presentation). +- Existing service/portal guarded browser suite: 47 grouped checks, 34 captures, + all six service routes at 320/390/768/1440px plus completion lifecycle checks + in EN/ES and both synthetic portal views. Evidence: + `/tmp/aia-service-portal-eqwVDU/evidence.json`. +- Browser environment: repository Nix browser shell, Playwright 1.61.1, + Chromium `149.0.7827.55` (actual executable version, not the earlier report's + Chromium 151 baseline). No new npm dependency or environment configuration. +- Focused correction rerun: 14 overlapping grouped checks, 10 captures, + `/tmp/aia-service-portal-CX1fu8/evidence.json`. This rerun adds explicit visible + viewport bounds and viewport-only acknowledgment screenshots. CSS-transition + time is allowed separately from virtual JavaScript timer time. Both checks + passed; overlapping counts are not additional unique route coverage. +- Desktop synthetic staff, mobile synthetic management and Spanish completion + screenshots were visually inspected. Browser automation is not real + screen-reader or fluent-language acceptance. +- The full browser run recorded 37 Jotform GET attempts fulfilled by local + fixtures; focused rerun recorded six. All non-loopback requests were intercepted + before transmission. Zero POST/DELETE, WebSocket, production API, analytics or + advertising requests were attempted. Zero external browser requests were sent. + +Reproduction from the existing browser development shell, after the isolated +build, is `node tools/service-portal-browser.mjs --correction-only`; omit that +flag to include existing timing/scroll/suppression regressions. Every external +request is intercepted, service workers are blocked, and portal ciphertext is +synthetic response injection. Screenshots remain outside Git. + +Evidence JSON SHA-256 values: + +- Full regression: `5ed8661fdec3a289f43aedeb17113586e4935542cf665ab94759d3aac8794d7a`. +- Focused correction: `36bc4804e86fe1d59c9547b8a629a45bef8baac10e34799b845fa1e7642e69fd`. + +Final validation: + +| Check | Result | +|---|---| +| `npm run check` (also executed by builds) | 151 files; zero errors, warnings, hints | +| `node --experimental-strip-types --test --test-concurrency=1 tools/*.test.mjs` | 202 passed, zero failed/skipped; includes Markdown, publication, SEO, Library, routes, campaign, Phase 8, form preview/isolation/artifact and service/portal suites | +| `npm run audit:links` | Zero broken internal links | +| `npm run audit:images` | Zero missing mapped page images | +| `npm run audit:blog` | Zero content failures | +| `git diff --check` | Passed | + +Documentation link review covered six changed documents. New links resolve. +One preexisting migration-summary link targets the archived +`route-reconciliation-2026-10-20-offline.json`; it is a historical reference +already documented by the handoff archive inventory, not an active dependency. +It remains preserved, and no archived report was read or modified. + +Every mode ran `npm run build` and the matching +`node tools/phase10-artifact-verify.mjs --mode=...`: + +| Build | Routes | Sitemap URLs | Hreflang | Immediate Form A frames | Deferred frames | +|---|---:|---:|---:|---:|---:| +| Isolated test, all consultation flags approved | 128 | 0 | 0 | 10 | 6 | +| Fully approved production artifact (local only) | 127 | 109 | 180 | 7 | 0 | +| Indexing, forms off | 127 | 109 | 180 | 0 | 0 | +| Mock (staging verifier) | 128 | 0 | 0 | 0 | 0 | +| Final restored off staging | 128 | 0 | 0 | 0 | 0 | + +All artifact checks passed with zero active native form actions. Final staging +has `noindex,nofollow` throughout. Production compilation never authorizes +deployment. Temporary review `/lp` remains absent from indexing artifacts. +Logs and sanitized artifact results are `/tmp/aia-correction-*.log` and +`/tmp/aia-correction-*-artifact.json`; no historical reconciliation was overwritten. + +During development, the artifact verifier correctly rejected an early shared +bundle containing the Form A ID. The receiver now derives expected origin/ID +from the already authorized iframe URL; subsequent off/mock and gated artifact +checks pass without weakening the verifier. Browser full-page captures were +supplemented with viewport acknowledgment captures and visible-bounds assertions. + +For consultation testing, set the existing six required values: + +```sh +PUBLIC_AIA_FORMS_MODE=isolated-test \ +PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test \ +PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=true \ +PUBLIC_AIA_FORMS_LIVE_APPROVED=false \ +PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true \ +PUBLIC_ALLOW_INDEXING=false npm run build +``` + +These settings are command-scoped; no local or deployed environment file changed. + +## Changed files + +Implementation: + +- `src/utils/jotform-completion.ts` (new strict completion validator). +- `src/scripts/service-inquiry.ts` (completion lifecycle and suppression). +- `src/components/ServiceInquiryDrawer.astro` (localized acknowledgment). +- `src/components/JotformIframe.astro` (controlled completed-frame reset). +- `src/utils/portal-presentation.ts` (new narrow presentation mapper). +- `src/scripts/employee-portal.ts` (passive presentation/semantic mapping). +- `src/components/pages/EmployeePortal.astro` (scoped styles). + +Tests: `tools/service-portal.test.mjs`, `tools/service-portal-browser.mjs`. +Documentation: this report, `reports/SESSION_HANDOFF.md`, +`reports/phase-9-form-a-owner-acceptance-2026-10-28.md`, +`reports/phase-9-two-form-integration-specification-2026-10-28.md`, +`reports/migration-summary.md`, `integrations/employee-portal/README.md`. +All paths above are relative to `www/`. + +## Owner acceptance and remaining blockers + +1. Perform one real synthetic consultation submission manually on the approved + isolated preview. Confirm actual `action`, `formID` and sender origin without + recording submitted answers; verify five seconds, native confirmation, + suppression, focus restoration, and fresh manual reopening. If the provider + changes origin/shape, the listener intentionally fails closed. Do not broaden + trust based on an unverified message. Check both locales' presentation. +2. Resolve the Spanish dropdown in Builder after comparing the direct hosted + form; confirm five choices, requiredness and language-specific validation. +3. Privately unlock both portal views and compare report buttons, directory + spacing and focus on desktop/mobile. Supply only sanitized class semantics + for any missing style mapping. Real resource access control/cutover remain + separate decisions. + +Seven production-eligible Form A routes remain unchanged. Production consultation +routes, portal cutover and site cutover remain unauthorized. No approval gate +is closed by fixtures, static artifacts or this checkpoint. diff --git a/www/reports/phase-9-form-a-owner-acceptance-2026-10-28.md b/www/reports/phase-9-form-a-owner-acceptance-2026-10-28.md index 74990be..4127fe7 100644 --- a/www/reports/phase-9-form-a-owner-acceptance-2026-10-28.md +++ b/www/reports/phase-9-form-a-owner-acceptance-2026-10-28.md @@ -57,3 +57,28 @@ The owner reports that Form A now has a `consultation` context with the required The new routes require the separate `PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true` flag together with every isolated-test gate. They remain absent from normal staging, mock, indexing-enabled, production-gated and deployed artifacts. The existing seven Form A contact/landing production-eligible routes are unchanged. The current decision supersedes the earlier plan for a conditional Autism Diagnostic Evaluation explanation. Do not display that note. The AIA client advocate will discuss evaluation options and coordination with qualified diagnostic providers in follow-up. Owner acceptance is still required for the actual dropdown's visibility, requiredness, choices, translations, conditional behavior, embedded submission/confirmation, error behavior and all nine route presentations. + +## 2026-10-30 completion-message and language diagnostic addendum + +The focused correction adds a narrow first-party completion listener only to +authorized consultation drawers. This explicitly supersedes any earlier blanket +no-message-listener description for that scope; it does not adopt Jotform's +resize handler or permit arbitrary origin messages. The support screenshot +documents `{ action: 'submission-completed', formID: '
' }`. +Exact origin, actual active iframe window, fixed Form A ID, open drawer and +idempotence are checked before acknowledgment/suppression and five-second closure. +No payload is logged, forwarded or used for analytics. Load is not success. + +Owner acceptance **remains pending** for the provider's real envelope and origin, +EN/ES acknowledgment, thank-you visibility, full delay, focus restoration and +fresh-form reopening. Previous owner-reported direct/embedded acceptance is +preserved; it does not establish this newly added message contract. + +The owner reports five English service choices but seven Spanish choices, +including General Question/Tour translations. Repository URL tests confirm fixed +consultation context and locale only; no service options originate in Astro. +Compare the direct hosted ES URL with the embedded result without submitting; +if both show seven choices, review Jotform multilingual translations and all +Update Options/show-hide conditions, including interaction with the inquiry +question. This is a Builder-boundary diagnosis, not an independently observed +Jotform configuration audit. No Jotform change is authorized or performed here. diff --git a/www/reports/phase-9-two-form-integration-specification-2026-10-28.md b/www/reports/phase-9-two-form-integration-specification-2026-10-28.md index a707675..7a11dd0 100644 --- a/www/reports/phase-9-two-form-integration-specification-2026-10-28.md +++ b/www/reports/phase-9-two-form-integration-specification-2026-10-28.md @@ -123,3 +123,23 @@ navigation change, new iframe allowance or publication entry. All original seven production and nine isolated consultation placements remain unchanged. No diagnostic conditional note or service preselection is introduced. The clinical and fluent-language review gates remain open. + +### 2026-10-30 completion and language correction addendum + +On a strictly validated Form A `submission-completed` message, an open activated +consultation drawer records the same shared suppression expiry immediately, +shows localized acknowledgment for five seconds, then uses the existing +slide-out/close/focus-restoration flow. The 500ms slide-out follows the full +five-second acknowledgment; reduced motion closes immediately after that delay. +Manual close/navigation cancels timers. A duplicate message is inert. A later +manual CTA starts a fresh iframe after completion; an unfinished iframe is +retained. This is not analytics, conversion measurement or answer inspection. +The exact source window and exact origin from the gated URL must match. No new +production route is approved. Provider behavior still requires owner acceptance. + +The owner-reported Spanish seven-option dropdown conflicts with the intended +five choices. Astro emits one fixed consultation iframe per surface; EN/ES differ +only in language. No option identifiers, duplicate intent field or service +preselection are emitted by Astro. Owner must compare the hosted Spanish form, +then review multilingual option translations and Update Options conditions. +Do not remove options through cross-origin scripts or add another physical form. diff --git a/www/src/components/JotformIframe.astro b/www/src/components/JotformIframe.astro index f87c066..b76b1d8 100644 --- a/www/src/components/JotformIframe.astro +++ b/www/src/components/JotformIframe.astro @@ -96,6 +96,7 @@ const headingId = `${idBase}-heading`; if (timer) window.clearTimeout(timer); frame.removeEventListener('load', onLoad); container.removeEventListener('aia:jotform-activate', activate); + container.removeEventListener('aia:jotform-reset', reset); window.removeEventListener('pagehide', dispose); document.removeEventListener('astro:before-swap', dispose); delete container.dataset.jotformFallbackInitialized; @@ -118,6 +119,20 @@ const headingId = `${idBase}-heading`; container.dataset.jotformIframeActivated = 'true'; }; + // A completed drawer is renewed only on a later deliberate opening. + // Replacing the frame also invalidates messages from its old WindowProxy. + const reset = () => { + if (container.dataset.jotformDeferred !== 'true' || container.dataset.formContext !== 'consultation') return; + dispose(); + const freshFrame = frame.cloneNode(false); + freshFrame.removeAttribute('src'); + frame.replaceWith(freshFrame); + delete container.dataset.jotformDocumentLoaded; + delete container.dataset.jotformIframeActivated; + initialize(container); + }; + container.addEventListener('aia:jotform-reset', reset); + if (container.dataset.jotformDeferred === 'true') { container.addEventListener('aia:jotform-activate', activate, { once: true }); } else { diff --git a/www/src/components/ServiceInquiryDrawer.astro b/www/src/components/ServiceInquiryDrawer.astro index eca072d..8243dee 100644 --- a/www/src/components/ServiceInquiryDrawer.astro +++ b/www/src/components/ServiceInquiryDrawer.astro @@ -169,6 +169,9 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === ' {consultationTestEnabled ?
+
:
diff --git a/www/src/components/pages/EmployeePortal.astro b/www/src/components/pages/EmployeePortal.astro index c3e0f0d..64a6377 100644 --- a/www/src/components/pages/EmployeePortal.astro +++ b/www/src/components/pages/EmployeePortal.astro @@ -36,7 +36,38 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') [hidden] { display: none; } .employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; } .employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; } - .employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; } - .employee-portal__content :global(td), .employee-portal__content :global(th) { padding: .5rem; } + .employee-portal__content { line-height: 1.65; } + .employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) { + color: var(--color-primary); font-family: var(--font-heading); line-height: 1.25; + margin-block: 2rem 1rem; + } + .employee-portal__content :global(:is(h1, h2)) { font-size: clamp(1.8rem, 3vw, 2.5rem); } + .employee-portal__content :global(h3) { font-size: clamp(1.4rem, 2vw, 1.8rem); } + .employee-portal__content :global(h4) { font-size: 1.25rem; } + .employee-portal__content :global(p) { margin-block: .75rem 1.25rem; } + .employee-portal__content :global(:is(ul, ol)) { padding-inline-start: 1.5rem; margin-block: 1rem 1.5rem; } + .employee-portal__content :global(li) { margin-block: .5rem; } + .employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; } + .employee-portal__content :global(hr) { border: 0; border-top: 1px solid var(--color-border); margin-block: 2rem; } + .employee-portal__content :global(a.portal-resource-button) { + display: inline-block; background: var(--color-accent); border: 2px solid var(--color-accent); + border-radius: var(--radius-sm); color: var(--color-primary); font-family: var(--font-body); + font-size: 1rem; font-weight: 400; line-height: 1.5; padding: .65rem 1.2rem; + margin-block: .35rem; max-width: 100%; text-decoration: none; + } + .employee-portal__content :global(a.portal-resource-button:is(:hover, :focus-visible)) { + background: transparent; border-color: var(--color-accent-strong); + } + .employee-portal__content :global(a.portal-contact-link) { + /* Dark orange preserves the source accent with readable contrast on white. */ + color: #a94600; text-underline-offset: .2em; + } + .employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; margin-block: 1.5rem; } + .employee-portal__content :global(td), .employee-portal__content :global(th) { + padding: .75rem; border-bottom: 1px solid var(--color-border); text-align: start; vertical-align: top; + } + .employee-portal__content :global(th) { background: var(--color-tint); color: var(--color-primary); } + .employee-portal__content :global(dt) { font-weight: 600; margin-top: 1rem; } + .employee-portal__content :global(dd) { margin-inline-start: 1rem; } @media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } } diff --git a/www/src/scripts/employee-portal.ts b/www/src/scripts/employee-portal.ts index ae09de1..d39ce1e 100644 --- a/www/src/scripts/employee-portal.ts +++ b/www/src/scripts/employee-portal.ts @@ -1,4 +1,5 @@ import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs'; +import { portalLinkPresentation } from '../utils/portal-presentation'; /** Build a passive DOM, never inject executable decrypted HTML. Remote media, * embedded forms and styles require owner review and are not loaded on unlock. */ @@ -21,9 +22,21 @@ export function safePortalContent(html: string): DocumentFragment { element.setAttribute('rel', 'noopener noreferrer'); element.setAttribute('referrerpolicy', 'no-referrer'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); + const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol); + if (presentation) element.className = presentation; } } catch { /* Invalid destination remains visible text without navigation. */ } } + // Preserve directory semantics without retaining arbitrary legacy attributes. + if (source.tagName === 'TH' && ['row', 'col', 'rowgroup', 'colgroup'].includes(source.getAttribute('scope') ?? '')) { + element.setAttribute('scope', source.getAttribute('scope')!); + } + if (['TD', 'TH'].includes(source.tagName)) { + for (const attr of ['colspan', 'rowspan']) { + const value = source.getAttribute(attr); + if (value && /^[1-9]\d?$/.test(value)) element.setAttribute(attr, value); + } + } for (const child of source.childNodes) copy(child, element); target.appendChild(element); }; diff --git a/www/src/scripts/service-inquiry.ts b/www/src/scripts/service-inquiry.ts index 2ec0f16..cd72dcc 100644 --- a/www/src/scripts/service-inquiry.ts +++ b/www/src/scripts/service-inquiry.ts @@ -1,4 +1,5 @@ import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference'; +import { isFormACompletion } from '../utils/jotform-completion'; let disposeAll: (() => void) | undefined; // Memory preference also covers storage-denied browsing during this document lifetime. @@ -23,6 +24,8 @@ export function initializeServiceInquiryDrawers() { .filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service); let timer: number | undefined; let closeTimer: number | undefined; + let completionTimer: number | undefined; + const acknowledgment = dialog.querySelector('[data-service-inquiry-completed]'); let firstFrame = 0; let secondFrame = 0; let trigger: HTMLElement | null = null; @@ -47,6 +50,11 @@ export function initializeServiceInquiryDrawers() { if (document.querySelector('dialog[open]')) return; autoConsumed = true; trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); + if (dialog.dataset.submissionCompleted === 'true') { + dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-reset')); + delete dialog.dataset.submissionCompleted; + if (acknowledgment) acknowledgment.hidden = true; + } dialog.showModal(); document.documentElement.classList.add('service-inquiry-open'); dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate')); @@ -59,7 +67,8 @@ export function initializeServiceInquiryDrawers() { const dismiss = () => { if (!dialog.open || closing) return; closing = true; - remember(); + clearTimeout(completionTimer); + if (dialog.dataset.submissionCompleted !== 'true') remember(); stopAuto(); dialog.classList.remove('is-open'); const finish = () => { dialog.close(); closing = false; }; @@ -74,6 +83,7 @@ export function initializeServiceInquiryDrawers() { dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal }); dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal }); dialog.addEventListener('close', () => { + clearTimeout(completionTimer); stopAuto(); dialog.classList.remove('is-open'); document.documentElement.classList.remove('service-inquiry-open'); @@ -81,6 +91,20 @@ export function initializeServiceInquiryDrawers() { trigger = null; }, { signal }); if (authorized) { + addEventListener('message', (event) => { + const container = dialog.querySelector('[data-jotform-form-a]'); + const frame = container?.querySelector('[data-jotform-iframe-frame]'); + const active = dialog.open && !closing && dialog.dataset.submissionCompleted !== 'true' && + container?.dataset.formContext === 'consultation' && container.dataset.jotformIframeActivated === 'true'; + if (!isFormACompletion(event, frame?.contentWindow, active, frame?.dataset.jotformIframeUrl)) return; + dialog.dataset.submissionCompleted = 'true'; + remember(); + if (acknowledgment) { + acknowledgment.hidden = false; + acknowledgment.scrollIntoView({ block: 'nearest', behavior: 'instant' }); + } + completionTimer = window.setTimeout(dismiss, 5000); + }, { signal }); for (const anchor of triggers) { anchor.setAttribute('aria-haspopup', 'dialog'); anchor.setAttribute('aria-controls', dialog.id); @@ -103,7 +127,7 @@ export function initializeServiceInquiryDrawers() { } addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal }); disposers.push(() => { - stopAuto(); clearTimeout(closeTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame); + stopAuto(); clearTimeout(closeTimer); clearTimeout(completionTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame); controller.abort(); dialog.classList.remove('is-open'); if (dialog.open) dialog.close(); for (const anchor of triggers) { anchor.removeAttribute('aria-controls'); anchor.removeAttribute('aria-haspopup'); } diff --git a/www/src/utils/jotform-completion.ts b/www/src/utils/jotform-completion.ts new file mode 100644 index 0000000..035b006 --- /dev/null +++ b/www/src/utils/jotform-completion.ts @@ -0,0 +1,18 @@ +/** Only the provider's completion envelope is used. Never retain/log answers. */ +export function isFormACompletion(event: { origin: string; source: unknown; data: unknown }, + frameWindow: unknown, active: boolean, approvedUrl: string | undefined): boolean { + if (!active || !frameWindow || event.source !== frameWindow || !approvedUrl) return false; + // This URL comes only from the server-rendered, gated route configuration, + // never from the message, current child location, query string or referrer. + let expected: URL; + try { expected = new URL(approvedUrl); } catch { return false; } + if (expected.protocol !== 'https:' || event.origin !== expected.origin) return false; + let data = event.data; + if (typeof data === 'string') { + if (data.length > 4096) return false; + try { data = JSON.parse(data); } catch { return false; } + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return false; + const message = data as Record; + return message.action === 'submission-completed' && message.formID === expected.pathname.slice(1); +} diff --git a/www/src/utils/portal-presentation.ts b/www/src/utils/portal-presentation.ts new file mode 100644 index 0000000..2d8cd91 --- /dev/null +++ b/www/src/utils/portal-presentation.ts @@ -0,0 +1,9 @@ +/** Translate a narrow presentation vocabulary; never copy arbitrary classes/styles. */ +export function portalLinkPresentation(classes: string[], background: string, protocol: string): string | undefined { + if (protocol === 'mailto:' || protocol === 'tel:') return 'portal-contact-link'; + if (classes.some((name) => ['hs-button', 'content-button', 'portal-resource-button'].includes(name)) || + ['rgb(255, 189, 89)', 'rgb(255, 145, 77)', '#ffbd59', '#ff914d'].includes(background.toLowerCase())) { + return 'portal-resource-button'; + } + return undefined; +} diff --git a/www/tools/service-portal-browser.mjs b/www/tools/service-portal-browser.mjs index e3c142b..6bba7f8 100644 --- a/www/tools/service-portal-browser.mjs +++ b/www/tools/service-portal-browser.mjs @@ -49,6 +49,7 @@ try { browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } : { headless: true }); + evidence.browser = browser.version(); preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { cwd: root, env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, @@ -67,9 +68,10 @@ try { const duration = 30 * 86400000; const staffCode = randomBytes(24).toString('hex'); const managementCode = randomBytes(24).toString('hex'); + const styledFixture = '

Synthetic Directory

Operational reports

Synthetic report

  • Synthetic list item
Fixture directory
RoleContact
Fixture roleFixture email

Second fixture report

Unsafe destination
'; const payload = { alg: portalAlgorithm, streams: [ - await encryptPortalStream('fixture-staff', '

Synthetic Staff

Staff fixture only

Synthetic resource', staffCode), - await encryptPortalStream('fixture-management', '

Synthetic Management

Management fixture only

', managementCode), + await encryptPortalStream('fixture-staff', '

Synthetic Staff

Staff fixture only

Synthetic resource' + styledFixture, staffCode), + await encryptPortalStream('fixture-management', '

Synthetic Management

Management fixture only

' + styledFixture, managementCode), ] }; const guard = async (context, inject = false) => { await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); }); @@ -91,7 +93,7 @@ try { return route.abort(); }); }; - if (!process.argv.includes('--portal-only')) { + if (!process.argv.includes('--portal-only') && !process.argv.includes('--correction-only')) { for (const width of widths) { for (const target of routes.filter((r) => r.drawer)) { const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' }); @@ -194,6 +196,92 @@ try { evidence.checks++; await backContext.close(); } + if (!process.argv.includes('--portal-only')) { + // Completion messages originate from a locally fulfilled cross-origin frame. + // No fixture submits anything; malformed browser envelopes are injected only in tests. + for (const locale of ['en', 'es']) { + const ctx = await browser.newContext({ serviceWorkers: 'block', viewport: { width: locale === 'es' ? 390 : 1440, height: 900 } }); + await guard(ctx); + await ctx.addInitScript((key) => localStorage.setItem(key, String(Date.now() + 86400000)), key); + const p = await ctx.newPage(); + await p.clock.install({ time: new Date('2026-10-30T12:00:00Z') }); + await p.clock.pauseAt(new Date('2026-10-30T12:00:01Z')); + await p.goto(origin + (locale === 'es' ? '/es/aba-therapy' : '/aba-therapy')); + const d = p.locator('[data-service-inquiry-drawer]'); + const ack = d.locator('[data-service-inquiry-completed]'); + const trigger = p.locator('[data-service-inquiry-trigger]'); + const send = async (changes = {}) => p.evaluate((changes) => { + const frame = document.querySelector('dialog iframe'); + const source = changes.wrongSource ? window : frame.contentWindow; + dispatchEvent(new MessageEvent('message', { origin: changes.origin ?? 'https://form.jotform.com', source, + data: { action: 'submission-completed', formID: '262946078314059', ...changes.data } })); + }, changes); + await send(); // Closed and unactivated. + assert.equal(await d.getAttribute('data-submission-completed'), null); + await trigger.click(); await p.clock.runFor(600); + // CSS transition rendering uses browser time, independently of mocked JS timers. + await delay(550); + await p.waitForFunction(() => [...document.querySelectorAll('iframe')].some(f => f.src.includes('consultation'))); + for (const wrong of [{ origin: 'https://form.jotform.com.evil.invalid' }, { wrongSource: true }, + { data: { formID: 'other' } }, { data: { action: 'setHeight' } }]) await send(wrong); + await p.clock.runFor(6000); + assert.equal(await d.evaluate(d => d.open), true); + assert.equal(await ack.isVisible(), false); + evidence.checks++; + const child = p.frames().find(f => f.url() === expectedUrl(locale)); + assert.ok(child, 'authorized fixture frame loaded'); + await child.evaluate(() => parent.postMessage({ action: 'submission-completed', formID: '262946078314059' }, '*')); + await p.waitForFunction(() => document.querySelector('dialog')?.dataset.submissionCompleted === 'true'); + assert.equal(await ack.isVisible(), true); + assert.match(await ack.textContent(), locale === 'es' ? /¡Gracias!/ : /Thank you!/); + const expiry = await p.evaluate(key => Number(localStorage.getItem(key)), key); + const now = await p.evaluate(() => Date.now()); + assert.equal(expiry, now + duration); + await p.clock.runFor(3000); await send(); // Duplicate must not restart countdown. + await p.clock.runFor(1999); + assert.equal(await d.evaluate(d => d.open && d.classList.contains('is-open')), true); + const acknowledgmentBox = await ack.boundingBox(); + assert.ok(acknowledgmentBox && acknowledgmentBox.x >= 0 && acknowledgmentBox.y >= 0 && + acknowledgmentBox.y + acknowledgmentBox.height <= 900, 'acknowledgment is inside the visible viewport'); + await p.screenshot({ path: path.join(output, `completion-${locale}.png`) }); + evidence.screenshots.push(`completion-${locale}.png`); + await p.clock.runFor(1); + assert.equal(await d.evaluate(d => d.open && !d.classList.contains('is-open')), true, 'slide-out starts after full 5 seconds'); + await p.clock.runFor(500); + assert.equal(await d.evaluate(d => d.open), false); + assert.equal(await trigger.evaluate(e => e === document.activeElement), true); + assert.equal(await p.evaluate(key => Number(localStorage.getItem(key)), key), expiry); + evidence.checks++; + await p.evaluate(() => window.oldFixtureWindow = document.querySelector('dialog iframe').contentWindow); + const count = evidence.jotformAttempts.length; + await trigger.click(); await p.clock.runFor(600); + assert.equal(await ack.isVisible(), false); + assert.equal(await d.locator('iframe').count(), 1); + assert.equal(await p.evaluate(() => window.oldFixtureWindow === document.querySelector('dialog iframe').contentWindow), false); + await p.waitForFunction(() => document.querySelector('[data-jotform-form-a]')?.dataset.jotformDocumentLoaded === 'true'); + assert.equal(evidence.jotformAttempts.length, count + 1); + await p.evaluate(() => dispatchEvent(new MessageEvent('message', { origin: 'https://form.jotform.com', source: window.oldFixtureWindow, + data: { action: 'submission-completed', formID: '262946078314059' } }))); + assert.equal(await ack.isVisible(), false); + await send(); await p.clock.runFor(1000); + await p.keyboard.press('Escape'); await p.clock.runFor(500); + assert.equal(await d.evaluate(d => d.open), false); + await trigger.click(); await p.clock.runFor(5000); + assert.equal(await d.evaluate(d => d.open), true, 'manual close canceled prior countdown'); + assert.equal(await ack.isVisible(), false); + evidence.checks++; + await send(); + await p.evaluate(() => document.dispatchEvent(new Event('astro:before-swap'))); + await p.clock.runFor(6000); + assert.equal(await d.evaluate(d => d.open), false); + await p.evaluate(() => document.dispatchEvent(new Event('astro:page-load'))); + await trigger.click(); await p.clock.runFor(6000); + assert.equal(await ack.isVisible(), false); + assert.equal(await d.evaluate(d => d.open), true); + evidence.checks++; + await ctx.close(); + } + } // Actual portal UI with synthetic encrypted response fixtures, never real private source. for (const width of widths) { const ctx = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block' }); await guard(ctx, true); @@ -212,6 +300,22 @@ try { assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0); assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true); assert.equal(await input.inputValue(), ''); + const checkPresentation = async () => { + const content = p.locator('[data-portal-content]'); + assert.equal(await content.locator('.portal-resource-button').count(), 2); + assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, iframe, form, input').count(), 0); + assert.equal(await content.locator('a[href^="javascript:"]').count(), 0); + assert.equal(await content.locator('th[scope="col"]').count(), 2); + const button = content.locator('.portal-resource-button').first(); + assert.equal(await button.evaluate(e => getComputedStyle(e).backgroundColor), 'rgb(255, 189, 89)'); + assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)'); + assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28)); + assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)'); + await button.focus(); + assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid'); + assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px'); + }; + await checkPresentation(); assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true }); evidence.screenshots.push('portal-synthetic-' + width + '.png'); @@ -220,6 +324,9 @@ try { await p.locator('[data-portal-content]').waitFor({ state: 'visible' }); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); + await checkPresentation(); + await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true }); + evidence.screenshots.push('portal-management-synthetic-' + width + '.png'); evidence.checks++; await ctx.close(); } for (const js of [false, true]) { diff --git a/www/tools/service-portal.test.mjs b/www/tools/service-portal.test.mjs index c9d6206..05b7125 100644 --- a/www/tools/service-portal.test.mjs +++ b/www/tools/service-portal.test.mjs @@ -8,6 +8,36 @@ import { isSuppressed, reachedHalf, suppressionDuration } from '../src/utils/dra import { serviceInquiryCopy } from '../src/data/service-inquiry.ts'; import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.mjs'; import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs'; +import { isFormACompletion } from '../src/utils/jotform-completion.ts'; +import { portalLinkPresentation } from '../src/utils/portal-presentation.ts'; + +test('completion envelope requires exact origin, active frame window and string Form A ID', () => { + const source = {}; + const check = (event, frame, active) => isFormACompletion(event, frame, active, + 'https://form.jotform.com/262946078314059?formContext=consultation&language=en'); + const data = { action: 'submission-completed', formID: '262946078314059' }; + const event = { origin: 'https://form.jotform.com', source, data }; + assert.equal(check(event, source, true), true); + assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true); + for (const change of [ + { origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'null' }, { origin: 'http://form.jotform.com' }, + { source: {} }, { source: null }, { data: { ...data, formID: 'other' } }, + { data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } }, + { data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) }, + ]) assert.equal(check({ ...event, ...change }, source, true), false); + assert.equal(check(event, source, false), false); + assert.equal(check(event, null, true), false); + assert.equal(isFormACompletion(event, source, true, undefined), false); + assert.equal(isFormACompletion(event, source, true, 'broken'), false); +}); + +test('portal presentation translates only explicit button tokens/brand fills and contact protocols', () => { + assert.equal(portalLinkPresentation(['hs-button', 'untrusted'], '', 'https:'), 'portal-resource-button'); + assert.equal(portalLinkPresentation([], 'rgb(255, 189, 89)', 'https:'), 'portal-resource-button'); + assert.equal(portalLinkPresentation(['arbitrary'], 'url(https://example.invalid)', 'https:'), undefined); + assert.equal(portalLinkPresentation([], '', 'mailto:'), 'portal-contact-link'); + assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link'); +}); test('global preference validates expiry, corrupted values and clock anomalies', () => { const now = 10000000000;