feat(portal): add service drawers and employee portal preview

This commit is contained in:
DeCentN2Madness committed 2026-10-30 00:03:21 -07:00
1 parent ff80ee516f
commit e97f0ea9b4
39 files changed
+1444 -128

No files matched your search

+3
View File
@@ -58,3 +58,6 @@ temp/
.DS_Store
Thumbs.db
Desktop.ini
# Private portal collection, mappings and ciphertext; never a public asset source.
/www/private/employee-portal/
+5
View File
@@ -18,3 +18,8 @@ PUBLIC_AIA_FORM_A_PRODUCTION_APPROVED=false
# Explicit, local-only Form A meeting preview; requires PUBLIC_AIA_FORMS_MODE=mock.
PUBLIC_AIA_FORM_A_PREVIEW=false
PUBLIC_ALLOW_INDEXING=false
# Employee portal ciphertext is an opt-in local compatibility preview only.
# Never supply a passphrase through environment variables or build arguments.
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false
# AIA_PORTAL_CIPHERTEXT_FILE=/owner-controlled/private/ciphertext.json
+101
View File
@@ -0,0 +1,101 @@
# Employee portal compatibility and private migration
Recorded 2026-10-30. Browser-side shared-code decryption is approved only for
local compatibility development. It is not authentication, individual revocation,
a secure session or a HIPAA compliance determination. Resources need separate
server authorization before cutover.
## Source and private collection
Read-only legacy footer source:
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`.
The original stays outside Git as rollback evidence. No access code was available
to the agent. The exact resource count and real content compatibility remain
unverified. Do not infer the earlier approximate 43 links as a measured count.
From `www`, in the owner's private interactive terminal:
```sh
node tools/employee-portal-migrate.mjs collect \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
Enter each legitimate view code at its hidden prompt. Never place codes in
arguments, environment variables, shell history or reports. The utility checks
both streams, keeps plaintext in memory, and collects only referenced `/hubfs/`
resources from the verified source origin. Other origins require explicit
review/code allowlisting; redirects are bounded and never inherit credentials.
No Jotform, Google or other vendor forms are fetched.
Private output is `private/employee-portal/`: directories 0700, manifests and
binary resources 0600. This exact subtree is gitignored and outside Astro's
public directory. Filenames are SHA-256 hashes of source URLs, independently
verified by content hashes. The manifest retains original references, labels,
view membership, MIME, bytes, status and delivery/classification decisions.
All new resources default to UNRESOLVED. Publicly reachable does not mean public
approved. Resuming verifies an existing file's content hash; mismatches are not
overwritten. HTML error documents, executable responses, invalid known signatures,
redirects outside approved destinations and resources over 50 MiB are rejected.
The first pass allows only the verified site origin. If a legitimate `/hubfs/`
response redirects to a HubSpot CDN, inspect that exact HTTPS origin first, then
add its origin to the private manifest's `approvedOrigins` list and rerun. Never
approve a redirect based only on a failed download or a broadly shared vendor
domain. Failures remain private review items. The utility prints counts only.
Before every artifact transfer, verify that only `dist` is copied and that it
contains no private manifest, document or real ciphertext unless that ciphertext
has separately been authorized for the local preview. Never archive the whole
working directory as a deployable artifact.
## Approved rewriting
The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL,
MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`,
`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for
internal documents) `destinationAccessControlled` only with actual evidence.
The utility does not create those destinations or infer their approval.
```sh
node tools/employee-portal-migrate.mjs rewrite \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
It reauthenticates both views, changes approved quoted href/src attributes only,
preserves unrelated HTML/labels, and re-encrypts with fresh salts and IVs. It
verifies round-trip decryption and distinct codes before writing new ciphertext
with exclusive filenames. The original source is untouched. CSS/srcset mappings
require separate review and are rejected rather than silently rewritten.
Unresolved links retain their original destination as a recorded dependency,
not a claim of verified destination health. A changed manifest alone never
changes links in the encrypted stream.
## Local preview and content safety
Use a private ciphertext file generated by `collect` or `rewrite`:
```sh
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \
AIA_PORTAL_CIPHERTEXT_FILE=/absolute/private/ciphertext-TIMESTAMP.json \
PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test \
PUBLIC_AIA_FORMS_MODE=off PUBLIC_ALLOW_INDEXING=false \
npm run dev -- --host 127.0.0.1
```
The page defaults to locked/unavailable without approved valid input. Indexing
builds cannot include it. The codes never go to a server, storage or URL. No
native form submits; Enter and Unlock use local WebCrypto. Only the matching
stream is displayed; refresh or leaving the page locks it again.
The passive DOM renderer preserves headings, lists, text, tables and safe
HTTPS/mailto/tel links, with no referrer on links. It rejects scripts, inline
handlers, styles, media, embedded forms and unknown tags. Real portal markup
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.
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.
Production must use a separately reviewed runtime ciphertext delivery mechanism;
this batch intentionally provides only a gated local build input. Browser-delivered
ciphertext is publicly downloadable by anyone able to reach that preview.
+72
View File
@@ -0,0 +1,72 @@
# Inactive SOPS/age preparation, 2026-10-30
The owner selected SOPS, age and sops-nix. This directory is a template, not a
host configuration. The development flake remains unchanged. No real recipient,
key, encrypted secret or server user is supplied. No module is deployed.
Official references inspected for this pass:
- [SOPS documentation](https://getsops.io/docs/)
- [sops-nix installation and secret permissions](https://github.com/Mic92/sops-nix)
SOPS encrypts configuration at rest. sops-nix can supply decrypted values at
runtime under `/run/secrets` with explicit owners and modes. This does not make
browser ciphertext or publicly served documents private.
## Owner setup before activation
1. Supply the actual NixOS topology, host flake, deploy user, persisted key
location, backup owner, HTTPS termination and resource-service identity.
2. Pin a reviewed `github:Mic92/sops-nix` input in that host flake; import
`inputs.sops-nix.nixosModules.sops` and the reviewed template. Do not add the
input to the Astro development flake just to evaluate a deployment sketch.
3. The owner generates independent developer/recovery and VPS age identities in
private terminals. Record only public recipients in the reviewed SOPS
creation rule. Use distinct recipients, never a placeholder recipient. Keep
the VPS private identity on persistent root-controlled storage (0600), outside
`/nix/store`, Git, the public static root and CI artifacts.
4. Encrypt the approved credential material using SOPS with both recipients.
Review recipient access, least privilege and an offline recovery copy. SOPS
encrypted files may enter a deployment repository only after owner approval.
5. Set `aia.portalSecrets` options explicitly, then evaluate the complete host
configuration and test decryption on authorized nonproduction infrastructure.
Runtime consumers use the generated `.path`, not Nix evaluation of plaintext.
6. Decide which service reloads after rotation. Rotate application credentials
as well as SOPS recipients when revoking access: rewrapping alone does not
invalidate an already copied password. Test recovery and key restoration.
## Separate staff and management resource authorization
Keep original documents outside Astro `public`, `dist`, release archives and
CDN roots. Proposed delivery namespaces are `/staff-resources/` and
`/management-resources/`; neither is implemented or considered working here.
Use nginx `auth_request` to an approved authorization service or an equivalent
server-enforced policy with individually revocable identities and staff and
management roles. Staff credentials must not authorize management resources;
management access to staff resources must be an explicit policy. Backend file
locations must be internal, inaccessible through static aliases or alternate
paths. Never trust a browser-provided role header.
A separate shared Basic Auth credential file per namespace can be considered
only as an explicitly approved weaker interim model, not identity management or
automatic role inheritance. The template prepares separate secret inputs without
selecting that access model or installing an identity provider.
Use HTTPS, `Cache-Control: private, no-store`, no shared/CDN caching, and
`Referrer-Policy: no-referrer` for protected delivery. Avoid credentials and
sensitive resource paths in access/error logs; review monitoring and backups.
Authorization failures must fail closed, including at proxy/cache boundaries.
Test direct requests, alternate encodings, role crossover and rollback.
## Distinct boundaries and release checks
- SOPS: deployment secrets at rest and controlled runtime access.
- Portal HTML: shared-code browser decryption, offline-guessable access friction.
- Documents: require server authorization even if URLs are encrypted in the UI.
The compatibility preview cannot grant production portal access. Before cutover,
classify every document, implement and verify its delivery destination, rewrite
and re-encrypt links, test two-view separation, rotate shared codes privately,
check the public artifact for private files, and obtain owner/security approval.
Back up encrypted configuration and documents separately from keys; rehearse
restore and rollback. Revoking a static release cannot retract ciphertext or
files already downloaded by visitors. Site cutover is still unauthorized.
+26
View File
@@ -0,0 +1,26 @@
# INACTIVE TEMPLATE. Not imported by the development flake or any deployed host.
# Requires an owner-reviewed sops-nix NixOS module import in the host flake.
{ config, lib, ... }:
let cfg = config.aia.portalSecrets;
in {
options.aia.portalSecrets = {
enable = lib.mkEnableOption "owner-reviewed portal deployment secrets";
encryptedFile = lib.mkOption { type = lib.types.path; description = "SOPS-encrypted YAML only; never a plaintext file"; };
ageKeyFile = lib.mkOption { type = lib.types.str; description = "Private persisted host age key path outside the Nix store"; };
readerUser = lib.mkOption { type = lib.types.str; description = "Approved resource authorization service user"; };
};
config = lib.mkIf cfg.enable {
assertions = [{ assertion = lib.hasPrefix "/" cfg.ageKeyFile && !(lib.hasPrefix "/nix/store/" cfg.ageKeyFile);
message = "The host age identity must be an absolute runtime path outside the Nix store."; }];
sops.defaultSopsFile = cfg.encryptedFile;
sops.age.keyFile = cfg.ageKeyFile;
sops.age.generateKey = false;
sops.age.sshKeyPaths = [];
sops.gnupg.sshKeyPaths = [];
sops.secrets."portal/staff-credentials" = { owner = cfg.readerUser; mode = "0400"; };
sops.secrets."portal/management-credentials" = { owner = cfg.readerUser; mode = "0400"; };
# Consumers reference config.sops.secrets.<name>.path at runtime.
# Do not use builtins.readFile on a decrypted secret, environment.etc.text,
# PUBLIC_* values, or Astro/Vite imports to transport secrets.
};
}
+29 -2
View File
@@ -1,9 +1,9 @@
# AIA Astro Migration — Living Session Handoff
**Last updated:** October 29, 2026 (UTC)
**Last updated:** October 30, 2026 (UTC)
**Repository:** `AIAMarketing/azinstitute4autism.com`
**Working branch:** `faithful-astro-migration`
**Last accepted technical implementation:** Phase 9 bounded landing-page integration (presentation and inactive lead UI only; human decisions and production activation pending)
**Last accepted technical implementation:** Phase 9 isolated consultation integration at `ff80ee516f0185023af1f29937e29dfb30d31f47`; this handoff also describes the consolidated service-drawer and employee-portal checkpoint candidate
**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.
**Next milestone:** continue Phase 9 owner decisions and authorized integration batches; then Phase 10 — Final QA/Cutover Readiness.
@@ -374,3 +374,30 @@ The old `.phase7-stage-paths.nul` is absent; do not recreate it.
The staging aid is for the user's host terminal only; never stage it or use a
broad Git add. The actual future checkpoint SHA comes from Git after the user
signs; do not amend merely to insert it here.
## Consolidated service drawers and portal checkpoint, 2026-10-30
This section supersedes earlier explicit-only drawer behavior. The checkpoint
containing this update adds four-second/50%-scroll consultation opening, one
shared 30-day localStorage expiry preference, manual override through the six
existing EN/ES service CTAs, and typed bilingual service introductions. The
Academy configuration is prepared without a new page. The fixed seven production
and separate nine consultation-test route lists remain unchanged. Consultation
indexing approval must explicitly be `false`.
The employee route now has a locked, local-decryption UI and a private owner-run
collection/rewrite utility. Real passphrases were unavailable: real portal
content/resource inventory and collection remain blocked pending the owner-run
step. Do not claim approximately 43 resources as a measured inventory. Do not
commit the original source, ciphertext, decrypted views or private resources.
`www/private/employee-portal/` is ignored and outside Astro public output.
The accepted Jotform CSS backup and reconciliation archives are unchanged.
SOPS/age/sops-nix preparation is inactive, with no generated production identity,
recipient, deployed module or server-access activation. Portal browser encryption
remains access friction; internal resources require separate server-enforced
staff/management authorization. See
[consolidated implementation and validation](phase-9-service-portal-consolidated-2026-10-30.md)
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.
@@ -153,3 +153,26 @@ This is **documentation only**. It must not change Phase 3A publication policy,
Phase 3B hreflang implementation, Astro routing, production access, forms,
analytics, or server configuration. It does not claim authentication,
HIPAA compliance, or release readiness.
## 2026-10-30 owner authorization and implementation addendum
The owner explicitly authorized the two-stream browser-encrypted compatibility
model for development, superseding the earlier implementation hold for that
bounded scope. Production authorization and server-side resource protection are
still unresolved. The new scoped EmployeePortal component preserves the existing
route/SEO policy, local password entry, HMAC verification before decryption,
locked/unavailable default and passive content rendering.
The read-only source is at
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`.
Its two-stream structure and algorithm were inspected without exposing ciphertext
or plaintext. Legitimate codes were not available to the agent. Real-view
fidelity and exact resource counts remain blocked, not accepted. No real document
was fetched or published. The owner-run migration utility performs private
collection and approved link rewriting/re-encryption; see
[private workflow instructions](../integrations/employee-portal/README.md).
SOPS/age/sops-nix templates are prepared but inactive. Encryption of deployment
secrets does not authorize public delivery of staff/management documents. The
[consolidated report](phase-9-service-portal-consolidated-2026-10-30.md) records
synthetic tests and the separate remaining owner/cutover gates.
+17
View File
@@ -4333,3 +4333,20 @@ The owner also authorized `/lp/early-intervention`. It is migrated independently
At the owner's request, added a simple five-link review index modeled on the publicly retrieved `https://astro.web3app.dev/` index (normal TLS). `src/pages/lp/[...review].astro` generates only `/lp`, only when `PUBLIC_ALLOW_INDEXING` is not `true`. It is explicitly labeled temporary/testing, always `noindex,nofollow`, absent from sitemap/hreflang and normal navigation, and links only to eligible local landing-page records. No submissions, scripts, tracking or external assets were added. Production/indexing-enabled output remains 127 routes; normal staging has 128, including this review utility. Indexing is a build gate, not access control: never deploy the staging artifact as a production release.
Build and link audit pass. Library regression coverage checks the exact five destinations, staging robots, sitemap exclusion and absence from the indexing-enabled build. Guarded Chromium checks at 320/1440px pass with visible keyboard focus, no overflow and all five local links returning 200; no external browser requests occurred. Screenshots and browser evidence are temporary at `/tmp/aia-campaign-browser-HdWNvy/`. Prior five-page integration evidence remains historical; the new offline reconciliation records the extra staging utility. Nothing was staged, committed or deployed.
## 2026-10-30 service and employee-portal development revision
Owner-authorized intentional deviations: service CTAs now say Request a
Consultation / Solicitar una consulta and progressively open authorized test
drawers; automatic timing is four seconds or half-page scroll with a shared
30-day dismissal preference. Service-specific bilingual copy replaces generic
introductions. The same page layout, slide-in treatment and iframe restrictions
remain. Live public read-only captures were cache-qualified editorial reference,
not a new clinical approval.
The metadata-only employee route now includes the source-backed Access Code
Required interface. Real encrypted views remain unavailable without private
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).
@@ -64,3 +64,16 @@ P9-01 through P9-10 remain **Pending decision**. No technical feasibility result
## Recommended checkpoint boundaries
Keep the existing Phase 9 implementation, audit tooling and reports as one owner-reviewed technical batch. If a signed checkpoint is requested, stage exact paths only after reviewing the final status and reports. Keep any subsequent host rehearsal, Jotform configuration, counter live adapter and measurement work in separately authorized commits.
## 2026-10-30 portal and secrets preparation addendum
The local portal compatibility implementation does not close P9-02 or P9-09.
The owner must supply legitimate codes privately to collect and classify legacy
resources, accept the two decrypted views, approve delivery per resource and
verify server-side staff/management separation. SOPS preparation is an inactive
[module and operational guide](../integrations/nixos/README.md), not deployed
infrastructure. Real age recipients, host topology, access policy, recovery and
runtime key provisioning remain pending. Do not copy `private/` into a release.
The artifact verifier now rejects private resource paths and portal ciphertext
from release artifacts. A local ciphertext preview is deliberately not a
releasable artifact. No site cutover authorization is implied.
@@ -76,3 +76,15 @@ Focused resolver/artifact/nonproduction tests passed **21/21**. The existing For
| Consultation isolated-test | 128 | 0 | 0 | 10 | 6 |
In the isolated artifact, ten static frames are the seven frozen contact/landing paths plus the three standalone consultation pages. The six service-drawer adapters retain an approved source only as deferred data until a visitor opens the dialog. The final normal staging build was restored: 128 `noindex,nofollow` pages, an empty sitemap, zero hreflang links and zero Form A frames. Link, image and blog audits reported 0 broken internal links, 0 missing mapped page images and 0 blog-audit failures.
## Later consolidated owner revision, 2026-10-30
The explicit-only drawer behavior above is the earlier checkpoint's historical
evidence. The subsequent consolidated pass replaces it with four-second/50%
automatic opening, global 30-day expiry-only suppression, existing CTA override
and bilingual service-specific introductory copy. Iframe sources remain deferred
until opening, which may now be automatic under the authorized test mode.
The production allowlist is unchanged. See the current
[consolidated report](phase-9-service-portal-consolidated-2026-10-30.md) for tests,
privacy boundaries and remaining owner acceptance. Historical test counts above
are not claims about the revised behavior.
@@ -0,0 +1,225 @@
# Consolidated service-drawer and employee-portal development
Date: 2026-10-30 UTC. Starting branch: `faithful-astro-migration`.
Starting HEAD: `ff80ee516f0185023af1f29937e29dfb30d31f47`.
Tracked tree and index were clean; only owner-provided `prompt.txt` was untracked.
This is a local development checkpoint, not production authorization.
## Service implementation
The six existing EN/ES ABA Therapy, Autism Evaluations and Learner Social Club
pages retain their URLs and consultation anchor destinations. Each existing
anchor now has an explicit service trigger and accurate consultation label.
Only an authorized isolated-test drawer intercepts ordinary anchor activation;
modified clicks and no-JavaScript/unauthorized builds preserve navigation.
No competing opener is added.
`service-inquiry.ts` owns four-second and half-scroll triggers, first-trigger-wins,
positive scroll-distance handling, restored/changed height, resize observation,
disposal, reduced-motion close, native dialog focus and close behavior. Competing
modal/field editing cancels an automatic opportunity without polling. Closing
stores one shared 30-day expiry timestamp, including manual-close renewals.
Invalid, expired and implausibly future values are ignored. Storage denial uses
memory for the current document; across full navigation suppression cannot be
guaranteed when storage is disabled or cleared. No service or entered value is
stored. Closing/reopening preserves the one deferred iframe and its data.
`service-inquiry.ts` does not modify Jotform or detect submissions. The local
preference is functional only. Service-specific EN/ES copy is centralized in
`data/service-inquiry.ts`, including an unpublished Academy configuration without
an invented route. ABA 18 months–8 years, Academy 2–6 and Social Club 8–17 are
kept distinct. No conditional diagnostic note, booking promise or preselection
is added. Existing evaluation-page body claims still need the separately tracked
clinical/editorial review; this pass changes CTAs and drawer copy only.
The separate nine-route consultation gate remains isolated-test only. A missing
or malformed indexing flag previously passed that consultation check; it now
requires explicit `PUBLIC_ALLOW_INDEXING=false`. The seven production-eligible
contact/landing paths are unchanged. Full test configuration:
```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
```
## Portal implementation and fidelity boundary
The scoped `EmployeePortal.astro` preserves the route, metadata/noindex, sitemap
exclusion and lack of hreflang. It provides the source-backed access-code UI,
local Enter/Unlock handling without a native submitting form, accessible feedback,
HMAC-before-decrypt compatibility, two-stream selection, focused passive content,
password clearing after unlock, refresh/BFCache relocking and no-JavaScript help.
No plaintext/code goes to a server, browser storage or URL.
The original source remains read-only outside Git. Structural inspection verified
two streams, PBKDF2-SHA256 200,000 iterations, AES-256-CBC and HMAC-SHA256 over
IV plus ciphertext, with 16-byte salt/IV and 32-byte MAC. No legitimate passphrase
was available. An independently constructed Node crypto fixture establishes
algorithm compatibility, but actual legacy decryption has not been performed.
Real content layout, categories, contacts and linked workflows remain unverified.
Passive DOM reconstruction preserves text, headings, lists, tables and safe
links. Scripts, handlers, inline CSS, media and embedded forms are not executed.
Any real-source reliance on removed markup requires review; visual parity is not
claimed. Shared-code ciphertext remains offline-guessable access friction,
not authentication or a compliance certification.
`portal-input.mjs` accepts only an explicit local-preview approval, isolated
role, false indexing and validated private ciphertext. Default, missing and
production-shaped builds remain unavailable. The release verifier rejects portal
ciphertext and private paths. No real ciphertext or resource is committed.
## Sanitized resource reconciliation
| Measure | Verified result |
|---|---|
| Original encrypted views | 2; structural metadata only |
| Total original /hubfs/ references | Unknown, decryption blocked |
| Distinct resources / duplicates | Unknown, not assumed to be 43 |
| Successfully downloaded real resources | 0 |
| Failed/unavailable resource downloads | Not tested; no decrypted URLs available |
| Public-approved resources | 0 approved by this task |
| Staff/internal and management/internal counts | Unknown pending decryption/classification |
| Unresolved resource classifications | All real inventory remains unreviewed; count unknown |
| Real links rewritten | 0 |
| Links awaiting protected destinations | Unknown pending inventory |
| Remaining HubSpot dependencies | Unresolved encrypted references; not declared migrated |
| SOPS/NixOS | Inactive template prepared; no real keys or deployment |
The owner-run utility extracts decrypted references in memory, deduplicates by
source URL, collects bounded HTTPS resources into ignored 0700 directories,
retains hashes/size/MIME/status in a private 0600 manifest, and defaults to
UNRESOLVED. It rejects unapproved redirects and executable/error responses.
Approved rewriting requires implemented/verified destinations and server access
control for internal files; it regenerates salts/IVs, verifies round trips and
keeps the original as rollback. No mapping is considered migrated merely because
a proposed URL exists. See [owner commands](../integrations/employee-portal/README.md).
## Source and infrastructure evidence
Read-only public source references on this date:
[ABA](https://www.azinstitute4autism.com/aba-therapy),
[evaluations](https://www.azinstitute4autism.com/autism-evaluations),
[Social Club](https://www.azinstitute4autism.com/learner-social-club),
[locked portal](https://www.azinstitute4autism.com/employee-portal).
These were cache-qualified text captures. No authenticated live view or private
source content was retrieved through the web tool. The owner-provided modal copy
is the current development authority; fluent review remains pending.
The existing development flake and deployment plan were inspected. The inactive
[NixOS guide](../integrations/nixos/README.md) cites current official SOPS/sops-nix
documentation. It separates deployment encryption, browser access friction and
server resource authorization. No host topology, real key/recipient or access
service is invented. The module is not imported, activated or deployed.
## Validation
`npm run check` passed across 149 Astro files with zero diagnostics. Sequential
core suites passed: Markdown 20/20, publication 33/33, SEO 35/35, Library 53/53,
routes 10/10, campaigns 11/11 and Phase 8 contrast 5/5. The combined current
Form A, nonproduction, artifact, drawer, crypto and resource tests passed 33/33;
the narrower final artifact/resource rerun passed 18/18. The inactive Nix module
also passed `nix-instantiate --parse`.
Mock, indexing-enabled, production-gated, isolated-test and final off/staging
builds passed the artifact verifier. The isolated artifact contains 128 HTML
routes, ten approved immediate Form A frames and six approved deferred drawer
frames. The production-gated artifact retains exactly the original seven Form A
frames. The final restored staging artifact contains 128 `noindex,nofollow`
routes, an empty sitemap, zero hreflang and zero Form A frames. Link, image and
blog audits each reported zero failures. `git diff --check` passed.
Guarded Chromium results:
- Revised service/portal runner: 39 checks, 28 screenshots, six EN/ES services
at 320/390/768/1440px, timer and scroll races, shared suppression, storage and
modal/editing failure cases, lifecycle reinitialization, and synthetic staff
and management portal unlocks. It intercepted 31 fixed Jotform GET attempts.
- Nine-placement consultation regression: 45 checks, 36 screenshots and one
held-request fallback check; 40 fixed Jotform GET attempts intercepted.
- Existing seven-placement Form A regression: 35 checks, 28 screenshots and
four fallback-state checks; 38 Form A GET attempts intercepted.
- Off-mode browser verification attempted no Jotform request and emitted no
Form A adapter.
The browser guards recorded attempted requests before fulfillment or blocking.
No non-GET/HEAD form request, Jotform submission, production API call, portal
password transfer, advertising request, analytics request or WebSocket was
transmitted. Child Form A responses were local fixtures; these runs do not prove
real Jotform application behavior. Portal fixtures used random synthetic codes
created in memory and were never written to Git.
Final ephemeral evidence is outside Git: revised service and synthetic portal
screenshots/ledger at `/tmp/aia-service-portal-uncmM3`, consultation regression
at `/tmp/aia-form-a-consultation-zMsmpB`, and the frozen seven-placement Form A
regression at `/tmp/aia-form-a-iframe-77duED`. The normal staging artifact was
restored last and passed with 128 routes, zero sitemap/hreflang/Form A frames and
zero active native form actions.
## Owner acceptance and cutover dependencies
1. On loopback isolated preview, open each EN/ES service page at 320, 390, 768 and
1440px. Verify four-second opening and (after clearing the functional preference)
half-scroll opening; dismiss, navigate across services/locales, verify suppression
and manual CTA reopening. Inspect real Form A errors, Submit reachability and
focus across the frame. Do not infer child application success from frame load.
2. Review provisional Spanish/service copy and real assistive-technology behavior.
3. Run the private collection utility with legitimate codes, review local manifest,
classify resources, resolve failed/redirected downloads and approve destinations.
4. Test real staff and management decryption with local ciphertext preview, inspect
sanitizer effects, links, responsive directories, keyboard and refresh behavior.
5. Supply actual NixOS topology and separate age recipients privately, approve
staff/management authorization and recovery, and verify delivery before rewriting.
6. Forms C, production consultation routes, portal access, protected document
serving and the overall site cutover all require separate authorization.
## Exact checkpoint file inventory
- `.gitignore`
- `www/.env.example`
- `www/integrations/employee-portal/README.md`
- `www/integrations/nixos/README.md`
- `www/integrations/nixos/portal-secrets.nix`
- `www/reports/SESSION_HANDOFF.md`
- `www/reports/employee-portal-migration-brief-2026-10-08.md`
- `www/reports/migration-summary.md`
- `www/reports/phase-10-technical-readiness-package-2026-10-22.md`
- `www/reports/phase-9-form-a-consultation-isolated-test-2026-10-29.md`
- `www/reports/phase-9-service-portal-consolidated-2026-10-30.md`
- `www/reports/phase-9-two-form-integration-specification-2026-10-28.md`
- `www/src/components/Button.astro`
- `www/src/components/JotformIframe.astro`
- `www/src/components/ServiceInquiryDrawer.astro`
- `www/src/components/pages/EmployeePortal.astro`
- `www/src/content/pages/en/aba-therapy.mdx`
- `www/src/content/pages/en/autism-evaluations.mdx`
- `www/src/content/pages/en/learner-social-club.mdx`
- `www/src/content/pages/es/aba-therapy.mdx`
- `www/src/content/pages/es/autism-evaluations.mdx`
- `www/src/content/pages/es/learner-social-club.mdx`
- `www/src/data/service-inquiry.ts`
- `www/src/layouts/PageLayout.astro`
- `www/src/scripts/employee-portal.ts`
- `www/src/scripts/service-inquiry.ts`
- `www/src/utils/drawer-preference.ts`
- `www/src/utils/form-integration.ts`
- `www/src/utils/portal-crypto.mjs`
- `www/src/utils/portal-input.mjs`
- `www/tools/employee-portal-migrate.mjs`
- `www/tools/jotform-consultation-browser.mjs`
- `www/tools/jotform-form-a.test.mjs`
- `www/tools/phase10-artifact-verify.mjs`
- `www/tools/phase10-artifact.test.mjs`
- `www/tools/portal-resources.mjs`
- `www/tools/seo.test.mjs`
- `www/tools/service-portal-browser.mjs`
- `www/tools/service-portal.test.mjs`
The owner-provided `prompt.txt` remains untracked and outside the checkpoint.
Private directories contain no collected real resources in this run. Protected
`merge-plan.md`, archived reconciliations and the accepted Jotform CSS backup
are untouched.
@@ -100,3 +100,26 @@ This decision does not close P9-01, P9-03, P9-05, P9-06, P9-07, P9-09 or P9-10.
The owner reports that the Form A `consultation` context and its conditional rules have been configured through the connected Jotform app. The repository now permits the nine matrix placements only when every isolated-test gate is present, including `PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true`. Three standalone pages load the fixed consultation URL immediately; six English/Spanish service drawers retain no iframe `src` until the visitor explicitly opens them. This does not change the frozen seven-route production allowlist.
The earlier version of this specification required a conditional Autism Diagnostic Evaluation explanation. That requirement is superseded: no conditional explanatory note is required in Form A or Astro. The historical decision is preserved by this dated addendum; the current contract is the client-advocate conversation described above.
## 2026-10-30 consolidated drawer behavior revision
The later owner prompt supersedes explicit-only opening for the six EN/ES
service drawers. Authorized consultation-test drawers now open after four seconds
or 50% of positive available page-scroll distance, whichever occurs first.
Opening consumes both automatic triggers. A different open modal or actively
edited field cancels that automatic opportunity without polling. Deferred
Jotform loading begins only when the drawer actually opens.
Dismissal stores only `aia:service-inquiry:auto-suppressed-until`, a first-party
expiry timestamp for 30 days shared across services and locales. Existing
consultation anchors are explicitly marked as drawer triggers; they bypass
suppression manually and retain their consultation-page href without JavaScript
or an authorized drawer. Closing renews the preference. It is not an audience,
tracking identifier, service-selection record or form-response store.
Centralized bilingual headings/introductions use provisional owner-supplied
service copy. The Academy service key has EN/ES copy for ages 2–6 but no route,
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.
+3 -1
View File
@@ -1,6 +1,7 @@
---
interface Props {
href?: string;
serviceInquiry?: string;
variant?: 'filled' | 'outlined' | 'light';
size?: 'default' | 'small';
center?: boolean;
@@ -14,6 +15,7 @@ interface Props {
const {
href,
serviceInquiry,
variant = 'filled',
size = 'default',
center = false,
@@ -34,7 +36,7 @@ const classes = [
---
{href ? (
<a class:list={classes} href={href} target={target} rel={rel}><slot /></a>
<a class:list={classes} href={href} data-service-inquiry-trigger={serviceInquiry} target={target} rel={rel}><slot /></a>
) : (
<button class:list={classes} type={type} disabled={disabled} aria-describedby={ariaDescribedby}><slot /></button>
)}
+11 -2
View File
@@ -88,13 +88,20 @@ const headingId = `${idBase}-heading`;
if (timer) window.clearTimeout(timer);
// A late frame load supersedes the transport timeout. It is still not
// treated as a success, conversion, or cross-origin health signal.
container.dataset.jotformDocumentLoaded = 'true';
hideFallback();
};
const dispose = () => {
const dispose = (event) => {
disposed = true;
if (timer) window.clearTimeout(timer);
frame.removeEventListener('load', onLoad);
container.removeEventListener('aia:jotform-activate', activate);
window.removeEventListener('pagehide', dispose);
document.removeEventListener('astro:before-swap', dispose);
delete container.dataset.jotformFallbackInitialized;
if (event?.type === 'pagehide' && event.persisted) {
window.addEventListener('pageshow', () => { if (container.isConnected) initialize(container); }, { once: true });
}
};
const activate = () => {
@@ -103,9 +110,11 @@ const headingId = `${idBase}-heading`;
const approvedUrl = frame.dataset.jotformIframeUrl;
if (deferred && !approvedUrl) return;
activated = true;
if (container.dataset.jotformDocumentLoaded !== 'true') {
frame.addEventListener('load', onLoad, { once: true });
timer = window.setTimeout(showFallback, fallbackTimeoutMs);
if (deferred) frame.src = approvedUrl;
}
if (deferred && frame.getAttribute('src') !== approvedUrl) frame.src = approvedUrl;
container.dataset.jotformIframeActivated = 'true';
};
+10 -103
View File
@@ -8,7 +8,7 @@ import {
resolveMockFormResult,
} from '../utils/form-integration';
type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club';
import { serviceInquiryCopy, type ServiceKey } from '../data/service-inquiry';
type SupportedLocale = 'en' | 'es';
interface Props {
@@ -22,11 +22,13 @@ const serviceNames: Record<SupportedLocale, Record<ServiceKey, string>> = {
'aba-therapy': 'ABA Therapy',
'autism-evaluations': 'Autism Evaluations',
'learner-social-club': 'Learner Social Club',
'aia-preparatory-academy': 'AIA Preparatory Academy',
},
es: {
'aba-therapy': 'Terapia ABA',
'autism-evaluations': 'Evaluaciones de autismo',
'learner-social-club': 'Learner Social Club',
'aia-preparatory-academy': 'AIA Preparatory Academy',
},
};
@@ -116,6 +118,7 @@ const drawerCopy: Record<SupportedLocale, {
const { service, lang, route } = Astro.props;
const copy = drawerCopy[lang];
const serviceName = serviceNames[lang][service];
const serviceCopy = serviceInquiryCopy[service][lang];
const drawerId = `service-inquiry-${lang}-${service}`;
const titleId = `${drawerId}-title`;
const noteId = `${drawerId}-note`;
@@ -137,15 +140,6 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
---
{consultationTestEnabled && formAIframe.url && <>
<button
class="service-inquiry-drawer__open"
type="button"
data-service-inquiry-open
aria-controls={drawerId}
aria-haspopup="dialog"
>
{copy.open(serviceName)}
</button>
<noscript>
<p class="service-inquiry-drawer__noscript">
{copy.noScript} <a href={formAIframe.url} target="_blank" rel="noopener noreferrer">{copy.hostedLink}</a>
@@ -157,8 +151,8 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
id={drawerId}
class="service-inquiry-drawer"
data-service-inquiry-drawer
data-explicit-activation={consultationTestEnabled ? 'true' : undefined}
data-dismiss-key={consultationTestEnabled ? undefined : `aia-service-inquiry-dismissed:${lang}:${service}`}
data-consultation-test={consultationTestEnabled ? 'true' : undefined}
data-service={service}
aria-labelledby={titleId}
>
<button class="service-inquiry-drawer__close" type="button" data-service-inquiry-close aria-label={copy.close}>
@@ -168,14 +162,14 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
</button>
<header class="service-inquiry-drawer__intro">
<h2 id={titleId} data-service-inquiry-title tabindex="-1">{consultationTestEnabled ? copy.heading(serviceName) : serviceName}</h2>
<p>{consultationTestEnabled ? copy.introduction : copy.legacyStatus}</p>
<h2 id={titleId} data-service-inquiry-title tabindex="-1">{consultationTestEnabled ? serviceCopy.heading : serviceName}</h2>
<p>{consultationTestEnabled ? serviceCopy.introduction : copy.legacyStatus}</p>
{consultationTestEnabled && <p class="service-inquiry-drawer__privacy">{copy.privacy}</p>}
</header>
{consultationTestEnabled
? <div class="service-inquiry-drawer__iframe">
<JotformIframe config={formAIframe} heading={copy.heading(serviceName)} variant="drawer" deferUntilActivated hideHeading />
<JotformIframe config={formAIframe} heading={serviceCopy.heading} variant="drawer" deferUntilActivated hideHeading />
</div>
: <div class="service-inquiry-form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form="service" data-mock-result={mockResult} data-mock-success="Demo only: the local fixture completed without sending anything." data-mock-failed="Demo only: the local fixture simulated a failed submission." data-mock-unavailable="Demo only: the local fixture simulated an unavailable service.">
<div class="service-inquiry-form__grid">
@@ -278,95 +272,8 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
<script>
import { bindMockForms } from '../scripts/mock-form';
import { initializeServiceInquiryDrawers } from '../scripts/service-inquiry';
bindMockForms();
const initializeServiceInquiryDrawers = () => {
document.querySelectorAll('[data-service-inquiry-drawer]').forEach((element) => {
if (!(element instanceof HTMLDialogElement) || element.dataset.initialized === 'true') return;
element.dataset.initialized = 'true';
const explicitActivation = element.dataset.explicitActivation === 'true';
const closeButton = element.querySelector('[data-service-inquiry-close]');
const title = element.querySelector('[data-service-inquiry-title]');
const frameAdapter = element.querySelector('[data-jotform-form-a]');
const triggers = Array.from(document.querySelectorAll(`[data-service-inquiry-open][aria-controls="${element.id}"]`));
const dismissKey = element.dataset.dismissKey;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let closeTimer: number | undefined;
let trigger: HTMLElement | undefined;
const wasDismissed = () => {
try {
return dismissKey ? sessionStorage.getItem(dismissKey) === 'true' : false;
} catch {
return false;
}
};
const rememberDismissal = () => {
try {
if (dismissKey) sessionStorage.setItem(dismissKey, 'true');
} catch {
// Storage can be unavailable in privacy-focused browsing modes.
}
};
const finishClose = () => {
if (element.open) element.close();
document.documentElement.classList.remove('service-inquiry-open');
};
const closeDrawer = () => {
if (!element.open) return;
if (!explicitActivation) rememberDismissal();
element.classList.remove('is-open');
window.clearTimeout(closeTimer);
if (reduceMotion.matches) {
finishClose();
return;
}
closeTimer = window.setTimeout(finishClose, 500);
};
const openDrawer = (openedBy?: EventTarget | null) => {
if (element.open || (!explicitActivation && wasDismissed())) return;
trigger = openedBy instanceof HTMLElement ? openedBy : undefined;
element.showModal();
document.documentElement.classList.add('service-inquiry-open');
if (explicitActivation && frameAdapter instanceof HTMLElement) {
frameAdapter.dispatchEvent(new Event('aia:jotform-activate'));
}
requestAnimationFrame(() => requestAnimationFrame(() => {
element.classList.add('is-open');
if (closeButton instanceof HTMLElement) closeButton.focus({ preventScroll: true });
else if (title instanceof HTMLElement) title.focus({ preventScroll: true });
}));
};
closeButton?.addEventListener('click', closeDrawer);
triggers.forEach((openButton) => openButton.addEventListener('click', () => openDrawer(openButton)));
element.addEventListener('cancel', (event) => {
event.preventDefault();
closeDrawer();
});
element.addEventListener('click', (event) => {
if (event.target === element) closeDrawer();
});
element.addEventListener('close', () => {
element.classList.remove('is-open');
document.documentElement.classList.remove('service-inquiry-open');
if (trigger instanceof HTMLElement) {
trigger.focus({ preventScroll: true });
trigger = undefined;
}
});
if (!explicitActivation) window.setTimeout(() => openDrawer(undefined), 10_000);
});
};
initializeServiceInquiryDrawers();
</script>
@@ -0,0 +1,42 @@
---
import { readPortalInput } from '../../utils/portal-input.mjs';
import Button from '../Button.astro';
// Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build.
const payload = readPortalInput({
approved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED,
indexing: import.meta.env.PUBLIC_ALLOW_INDEXING,
environment: import.meta.env.PUBLIC_AIA_FORMS_ENVIRONMENT,
file: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
});
const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null';
---
<section class="employee-portal container" data-employee-portal>
<div data-portal-prompt>
<h2>Access Code Required</h2>
<p>Please enter the appropriate access code to view your Arizona Institute for Autism Employee Portal.</p>
<!-- Local unlock only: no native form, action, server submission or persistence. -->
<div class="employee-portal__access" role="group" aria-label="Employee portal access">
<label for="employee-portal-code">Access Code</label>
<input id="employee-portal-code" type="password" autocomplete="off" disabled aria-describedby="employee-portal-feedback" data-portal-code />
<Button type="button" disabled>Unlock</Button>
</div>
<p id="employee-portal-feedback" role="status" aria-live="polite" data-portal-feedback>Portal access is unavailable until JavaScript and approved encrypted content are available.</p>
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section>
<script>import '../../scripts/employee-portal';</script>
<style>
.employee-portal { padding-block: 3rem; overflow-wrap: anywhere; }
.employee-portal__access { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible, .employee-portal__content:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
[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; }
@media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } }
</style>
+1 -1
View File
@@ -50,7 +50,7 @@ Our goal is to empower families with the knowledge, tools, and support they need
These consultations provide parents with personalized guidance, training, and strategies tailored to their child’s unique needs.
<Button href="/client-consultation" center>Make an Appointment</Button>
<Button href="/client-consultation" serviceInquiry="aba-therapy" center>Request a Consultation</Button>
## About Rula Diab, Chief Clinical Director, BCBA, LBA
@@ -50,4 +50,4 @@ ADOS is a structured evaluation tool used by our specialists to assess behaviors
This evaluation involves observing how your child interacts with various play-based social scenarios. For younger children, this may include activities like playing with blocks or having a snack, while older children who communicate verbally may participate in conversations about daily activities, such as attending school.
<Button href="/client-consultation" center>Make an Appointment</Button>
<Button href="/client-consultation" serviceInquiry="autism-evaluations" center>Request a Consultation</Button>
@@ -53,4 +53,4 @@ The Learner Social Club currently serves youth learners aged **8–17 years**. O
Get a **1-Day Trial Pass** by emailing us at <ContactObfuscation email="info@azinstitute4autism.com" obfuscatedText="info [at] azinstitute4autism [dot] com" />.
<Button href="/client-consultation" center>Enroll Now</Button>
<Button href="/client-consultation" serviceInquiry="learner-social-club" center>Request a Consultation</Button>
+1 -1
View File
@@ -50,7 +50,7 @@ Nuestro objetivo es empoderar a las familias con el conocimiento, las herramient
Estas consultas brindan a los padres orientación, capacitación y estrategias personalizadas adaptadas a las necesidades únicas de su hijo.
<Button href="/es/client-consultation" center>Hacer una cita</Button>
<Button href="/es/client-consultation" serviceInquiry="aba-therapy" center>Solicitar una consulta</Button>
## Acerca de Rula Diab, Directora Clínica Principal, BCBA, LBA
@@ -50,4 +50,4 @@ ADOS es una herramienta de evaluación estructurada que utilizan nuestros especi
Esta evaluación implica observar cómo su hijo/a interactúa en diferentes situaciones sociales basadas en el juego. Para los niños más pequeños, esto puede incluir actividades como jugar con bloques o tomar una merienda, mientras que los niños mayores que se comunican verbalmente pueden participar en conversaciones sobre actividades cotidianas, como asistir a la escuela.
<Button href="/es/client-consultation" center>Programar una cita</Button>
<Button href="/es/client-consultation" serviceInquiry="autism-evaluations" center>Solicitar una consulta</Button>
@@ -61,4 +61,4 @@ Learner Social Club actualmente atiende a jóvenes de 8 a 17 años. Nuestras opc
Obtenga un pase de prueba de 1 día enviándonos un correo electrónico a <ContactObfuscation email="info@azinstitute4autism.com" obfuscatedText="info [at] azinstitute4autism [dot] com" />.
<Button href="/es/client-consultation">Enroll Now</Button>
<Button href="/es/client-consultation" serviceInquiry="learner-social-club">Solicitar una consulta</Button>
+22
View File
@@ -0,0 +1,22 @@
export type ServiceKey = 'aba-therapy' | 'autism-evaluations' | 'learner-social-club' | 'aia-preparatory-academy';
export type ServiceLocale = 'en' | 'es';
// Editorial defaults authorized for this development pass; fluent review pending.
// Configuration is intentionally independent of the published route allowlist.
export const serviceInquiryCopy: Record<ServiceKey, Record<ServiceLocale, { heading: string; introduction: string }>> = {
'aba-therapy': {
en: { heading: 'Explore ABA Therapy at AIA', introduction: 'Learn about individualized ABA support for children ages 18 months through 8 years. An AIA client advocate can explain how to get started.' },
es: { heading: 'Conozca la terapia ABA de AIA', introduction: 'Infórmese sobre el apoyo individualizado de terapia ABA para niños de 18 meses a 8 años. Un representante de AIA puede explicarle cómo comenzar.' },
},
'autism-evaluations': {
en: { heading: 'Questions About an Autism Evaluation?', introduction: 'Speak with an AIA client advocate about your questions and possible next steps.' },
es: { heading: '¿Tiene preguntas sobre una evaluación de autismo?', introduction: 'Hable con un representante de AIA sobre sus preguntas y los posibles próximos pasos.' },
},
'learner-social-club': {
en: { heading: 'Explore the Learner Social Club', introduction: 'Ask about group activities and social opportunities for youth ages 8–17. Our team can discuss program fit and next steps.' },
es: { heading: 'Conozca el Learner Social Club', introduction: 'Pregunte por las actividades grupales y las oportunidades sociales para jóvenes de 8 a 17 años. Nuestro equipo puede conversar con usted sobre el programa y los próximos pasos.' },
},
'aia-preparatory-academy': {
en: { heading: 'Explore AIA Preparatory Academy', introduction: 'Learn about individualized ABA support and purposeful group learning for children ages 2–6. Speak with AIA about academic readiness and next steps.' },
es: { heading: 'Conozca AIA Preparatory Academy', introduction: 'Infórmese sobre el apoyo individualizado de terapia ABA y el aprendizaje grupal con propósito para niños de 2 a 6 años. Hable con AIA sobre la preparación académica y los próximos pasos.' },
},
};
+3
View File
@@ -1,4 +1,5 @@
---
import EmployeePortal from '../components/pages/EmployeePortal.astro';
import BaseLayout from './BaseLayout.astro';
import FormShell from '../components/FormShell.astro';
import TeamPage from '../components/pages/TeamPage.astro';
@@ -64,6 +65,8 @@ if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team)
<PageHero title={entry.data.displayH1 ?? bannerTitle} subtitle={entry.data.slug === 'faqs' ? 'FAQs' : undefined} image={heroImage} constrain />
{entry.data.slug === 'team' && entry.data.lang === 'en'
? <TeamPage content={entry.data.team!} />
: entry.data.slug === 'employee-portal' && entry.data.lang === 'en'
? <EmployeePortal />
: entry.data.slug === 'about' && entry.data.lang === 'en'
? <AboutPage />
: entry.data.slug === 'services' && entry.data.lang === 'en'
+74
View File
@@ -0,0 +1,74 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
* embedded forms and styles require owner review and are not loaded on unlock. */
export function safePortalContent(html: string): DocumentFragment {
const template = document.createElement('template');
template.innerHTML = html;
const allowed = new Set(['DIV','SECTION','ARTICLE','HEADER','FOOTER','H1','H2','H3','H4','H5','H6','P','SPAN','STRONG','EM','B','I','U','SMALL','BR','HR','UL','OL','LI','DL','DT','DD','TABLE','THEAD','TBODY','TR','TH','TD','CAPTION','A']);
const output = document.createDocumentFragment();
const copy = (source: Node, target: Node) => {
if (source.nodeType === Node.TEXT_NODE) { target.appendChild(document.createTextNode(source.textContent ?? '')); return; }
if (!(source instanceof HTMLElement)) return;
if (!allowed.has(source.tagName)) return;
const element = document.createElement(source.tagName.toLowerCase());
if (source.tagName === 'A') {
const href = source.getAttribute('href') ?? '';
try {
const url = new URL(href, location.origin);
if (['https:', 'mailto:', 'tel:'].includes(url.protocol) || (url.origin === location.origin && url.protocol === location.protocol)) {
element.setAttribute('href', url.href);
element.setAttribute('rel', 'noopener noreferrer');
element.setAttribute('referrerpolicy', 'no-referrer');
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
}
} catch { /* Invalid destination remains visible text without navigation. */ }
}
for (const child of source.childNodes) copy(child, element);
target.appendChild(element);
};
for (const child of template.content.childNodes) copy(child, output);
return output;
}
function initialize() {
const root = document.querySelector<HTMLElement>('[data-employee-portal]');
if (!root || root.dataset.bound) return;
root.dataset.bound = 'true';
const input = root.querySelector<HTMLInputElement>('[data-portal-code]')!;
const button = root.querySelector<HTMLButtonElement>('button')!;
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
let data: ReturnType<typeof validatePortalPayload>;
try {
if (!crypto.subtle) throw new Error();
data = validatePortalPayload(JSON.parse(root.querySelector('[data-portal-payload]')?.textContent ?? 'null'));
input.disabled = false; button.disabled = false;
feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const unlock = async () => {
if (button.disabled) return;
button.disabled = true;
input.setAttribute('aria-invalid', 'false');
feedback.textContent = '';
let pass = input.value;
try {
for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass);
if (plain !== null) {
content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false;
input.value = ''; content.focus(); return;
}
}
input.setAttribute('aria-invalid', 'true'); feedback.textContent = 'Invalid Code'; input.focus();
} catch { feedback.textContent = 'Portal access is unavailable. Please try again or contact AIA.'; }
finally { pass = ''; button.disabled = false; }
};
button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
// BFCache must not restore an already unlocked view.
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
}
initialize();
document.addEventListener('astro:page-load', initialize);
+118
View File
@@ -0,0 +1,118 @@
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
let disposeAll: (() => void) | undefined;
// Memory preference also covers storage-denied browsing during this document lifetime.
let memoryExpiry: string | null = null;
function suppressed() {
try { return isSuppressed(localStorage.getItem(suppressionKey)) || isSuppressed(memoryExpiry); }
catch { return isSuppressed(memoryExpiry); }
}
function remember() {
memoryExpiry = String(Date.now() + suppressionDuration);
try { localStorage.setItem(suppressionKey, memoryExpiry); } catch { /* Functional preference only. */ }
}
export function initializeServiceInquiryDrawers() {
disposeAll?.();
const disposers: (() => void)[] = [];
document.querySelectorAll<HTMLDialogElement>('[data-service-inquiry-drawer]').forEach((dialog) => {
const controller = new AbortController();
const { signal } = controller;
const authorized = dialog.dataset.consultationTest === 'true';
const closeButton = dialog.querySelector<HTMLButtonElement>('[data-service-inquiry-close]');
const triggers = [...document.querySelectorAll<HTMLAnchorElement>('[data-service-inquiry-trigger]')]
.filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service);
let timer: number | undefined;
let closeTimer: number | undefined;
let firstFrame = 0;
let secondFrame = 0;
let trigger: HTMLElement | null = null;
let autoConsumed = false;
let closing = false;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
const stopAuto = () => {
clearTimeout(timer);
removeEventListener('scroll', checkScroll);
removeEventListener('resize', checkScroll);
observer?.disconnect();
};
const conflict = () => Boolean(document.querySelector('dialog[open]')) ||
document.activeElement?.matches('input:not([disabled]), textarea, select, [contenteditable="true"], iframe');
const open = (manual?: HTMLElement) => {
if (dialog.open) return;
if (!manual && (autoConsumed || suppressed())) { stopAuto(); return; }
// One opportunity, never interrupt another modal or an edited field; CTA remains usable.
if (!manual) autoConsumed = true;
stopAuto();
if (conflict() && !manual) return;
if (document.querySelector('dialog[open]')) return;
autoConsumed = true;
trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
dialog.showModal();
document.documentElement.classList.add('service-inquiry-open');
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
firstFrame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => {
if (!dialog.open || closing) return;
dialog.classList.add('is-open');
closeButton?.focus({ preventScroll: true });
}); });
};
const dismiss = () => {
if (!dialog.open || closing) return;
closing = true;
remember();
stopAuto();
dialog.classList.remove('is-open');
const finish = () => { dialog.close(); closing = false; };
if (reduceMotion.matches) finish();
else closeTimer = window.setTimeout(finish, 500);
};
function checkScroll() {
if (reachedHalf(scrollY, document.documentElement.scrollHeight, innerHeight)) open();
}
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(checkScroll) : undefined;
closeButton?.addEventListener('click', dismiss, { signal });
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
dialog.addEventListener('close', () => {
stopAuto();
dialog.classList.remove('is-open');
document.documentElement.classList.remove('service-inquiry-open');
if (trigger?.isConnected) trigger.focus({ preventScroll: true });
trigger = null;
}, { signal });
if (authorized) {
for (const anchor of triggers) {
anchor.setAttribute('aria-haspopup', 'dialog');
anchor.setAttribute('aria-controls', dialog.id);
anchor.addEventListener('click', (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (document.querySelector('dialog[open]')) return;
event.preventDefault(); open(anchor);
}, { signal });
}
if (!suppressed()) {
timer = window.setTimeout(() => open(), 4000);
addEventListener('scroll', checkScroll, { passive: true, signal });
addEventListener('resize', checkScroll, { passive: true, signal });
observer?.observe(document.documentElement);
checkScroll();
}
} else if (!suppressed()) {
// Retain the existing inactive/mock demonstration timer outside test mode.
timer = window.setTimeout(() => open(), 10000);
}
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
disposers.push(() => {
stopAuto(); clearTimeout(closeTimer); 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'); }
document.documentElement.classList.remove('service-inquiry-open');
});
});
disposeAll = () => { disposers.forEach((dispose) => dispose()); disposeAll = undefined; };
}
document.addEventListener('astro:before-swap', () => disposeAll?.());
document.addEventListener('astro:page-load', initializeServiceInquiryDrawers);
window.addEventListener('pagehide', () => disposeAll?.());
window.addEventListener('pageshow', (event) => { if (event.persisted) initializeServiceInquiryDrawers(); });
+12
View File
@@ -0,0 +1,12 @@
export const suppressionKey = 'aia:service-inquiry:auto-suppressed-until';
export const suppressionDuration = 30 * 24 * 60 * 60 * 1000;
export function isSuppressed(value: string | null, now = Date.now()): boolean {
if (!value || !/^\d+$/.test(value)) return false;
const expiry = Number(value);
// Reject corrupted values and clock jumps beyond the maximum preference life.
return Number.isSafeInteger(expiry) && expiry > now && expiry <= now + suppressionDuration;
}
export function reachedHalf(scrollY: number, scrollHeight: number, viewportHeight: number): boolean {
const distance = scrollHeight - viewportHeight;
return distance > 1 && Math.max(0, scrollY) / distance >= 0.5;
}
+2 -2
View File
@@ -117,7 +117,7 @@ export const formAIframePlacements: ReadonlyArray<FormAIframePlacement> = [
* Consultation placements are a separate, non-production allowlist. They
* cannot be activated by the production approval gate, even when every
* production value is true. Service drawers defer assigning their iframe src
* until an explicit visitor action.
* until the authorized drawer opens.
*/
export const formAConsultationTestPlacements: ReadonlyArray<FormAIframePlacement> = [
{ route: '/client-consultation', locale: 'en', context: 'consultation' },
@@ -196,7 +196,7 @@ export function resolveFormAIframe(options: ResolveFormAIframeOptions): FormAIfr
: standardPlacement;
if (!placement) return disabled('route-not-authorized');
if (options.locale !== placement.locale) return disabled('locale-not-authorized');
if (placement.context === 'consultation' && !isTrue(options.consultationTestApproved)) {
if (placement.context === 'consultation' && (options.allowIndexing !== 'false' || !isTrue(options.consultationTestApproved))) {
return disabled('consultation-test-not-approved');
}
+42
View File
@@ -0,0 +1,42 @@
// Legacy wire compatibility: PBKDF2 512 bits -> AES key then HMAC key.
// HMAC authenticates IV || ciphertext before AES-CBC decryption.
export const portalAlgorithm = 'AES-256-CBC+HMAC-SHA256';
const decode = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
const encode = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(''));
const concat = (a, b) => { const out = new Uint8Array(a.length + b.length); out.set(a); out.set(b, a.length); return out; };
export function validatePortalPayload(data) {
if (!data || data.alg !== portalAlgorithm || !Array.isArray(data.streams) || data.streams.length !== 2) throw new Error('Invalid portal payload');
const ids = new Set();
for (const s of data.streams) {
if (typeof s.id !== 'string' || ids.has(s.id) || s.iter !== 200000) throw new Error('Invalid portal stream');
ids.add(s.id);
for (const key of ['salt', 'iv', 'hmac', 'ct']) {
if (typeof s[key] !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(s[key]) || s[key].length > 8_000_000) throw new Error('Invalid portal encoding');
}
if (decode(s.salt).length !== 16 || decode(s.iv).length !== 16 || decode(s.hmac).length !== 32 || !decode(s.ct).length || decode(s.ct).length % 16) throw new Error('Invalid portal lengths');
}
// Strip all unrelated input properties; only ciphertext reaches an approved preview.
return { alg: portalAlgorithm, streams: data.streams.map(({ id, iter, salt, iv, hmac, ct }) => ({ id, iter, salt, iv, hmac, ct })) };
}
async function keys(pass, salt, iterations) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(pass), 'PBKDF2', false, ['deriveBits']);
const bits = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, 512));
const aes = await crypto.subtle.importKey('raw', bits.slice(0, 32), 'AES-CBC', false, ['decrypt', 'encrypt']);
const mac = await crypto.subtle.importKey('raw', bits.slice(32), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
bits.fill(0);
return { aes, mac };
}
export async function decryptPortalStream(stream, pass) {
const { aes, mac } = await keys(pass, decode(stream.salt), stream.iter);
const iv = decode(stream.iv), ct = decode(stream.ct);
if (!await crypto.subtle.verify('HMAC', mac, decode(stream.hmac), concat(iv, ct))) return null;
return new TextDecoder('utf-8', { fatal: true }).decode(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes, ct));
}
export async function encryptPortalStream(id, html, pass) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(16));
const { aes, mac } = await keys(pass, salt, 200000);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aes, new TextEncoder().encode(html)));
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', mac, concat(iv, ct)));
return { id, iter: 200000, salt: encode(salt), iv: encode(iv), hmac: encode(hmac), ct: encode(ct) };
}
+10
View File
@@ -0,0 +1,10 @@
// Build-only reader; never imported by a browser script.
import fs from 'node:fs';
import { validatePortalPayload } from './portal-crypto.mjs';
export function readPortalInput({ approved, indexing, environment, file }) {
if (approved !== 'true' || indexing !== 'false' || environment !== 'isolated-test' || !file) return undefined;
try {
if (fs.statSync(file).size > 12_000_000) return undefined;
return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8')));
} catch { return undefined; }
}
+90
View File
@@ -0,0 +1,90 @@
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
async function hiddenPrompt(label) {
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
process.stderr.write(label);
process.stdin.setRawMode(true); process.stdin.resume();
return new Promise((resolve, reject) => {
let value = '';
const finish = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
const onData = (data) => {
for (const char of data.toString('utf8')) {
if (char === '\u0003' || char === '\u0004') { finish(); reject(new Error('Canceled')); return; }
if (char === '\r' || char === '\n') { finish(); resolve(value); return; }
if (char === '\u007f' || char === '\b') value = value.slice(0, -1);
else if (char >= ' ') value += char;
}
};
process.stdin.on('data', onData);
});
}
async function main() {
const [command, source, ...extra] = process.argv.slice(2);
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt');
// No scanning for credentials. The original is read only; never copied into Git.
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8')));
await fs.mkdir(root, { recursive: true, mode: 0o700 });
await fs.chmod(root, 0o700);
const manifestPath = path.join(root, 'manifest.json');
let previous = { resources: [] };
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
const nextStreams = [], unlocked = [];
let rewritten = 0;
for (let index = 0; index < payload.streams.length; index++) {
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
const stream = payload.streams[index];
let html = await decryptPortalStream(stream, pass);
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
// Reject identical credentials across views to preserve stream separation.
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
for (const ref of resourceReferences(html, stream.id)) {
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
record.references.push(ref); resources.set(ref.sourceUrl, record);
}
if (command === 'rewrite') {
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id)));
html = result.html; rewritten += result.count;
const next = await encryptPortalStream(stream.id, html, pass);
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
nextStreams.push(next);
}
unlocked.push(stream.id); html = ''; pass = '';
}
const stamp = new Date().toISOString().replaceAll(':', '-');
if (command === 'collect') {
const approvedOrigins = Array.isArray(previous.approvedOrigins)
? previous.approvedOrigins.filter((origin) => {
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; }
catch { return false; }
})
: undefined;
for (const [url, record] of resources) {
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
}
const manifest = {
version: 1,
updated: stamp,
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
views: unlocked,
resources: [...resources.values()],
};
// Retain each previous manifest; no original evidence is overwritten.
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; }
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
} else {
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
}
const list = [...resources.values()];
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2));
}
main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; });
+4 -3
View File
@@ -65,6 +65,7 @@ try {
viewport: { width, height: width < 500 ? 844 : 960 },
serviceWorkers: 'block',
});
await context.addInitScript(() => localStorage.setItem('aia:service-inquiry:auto-suppressed-until', String(Date.now() + 86400000)));
await context.routeWebSocket('**/*', (socket) => socket.close());
await context.route('**/*', async (route) => {
const request = route.request();
@@ -103,7 +104,7 @@ try {
const attemptsBefore = evidence.jotformAttempts.length;
if (expected.drawer) {
const opener = page.locator('[data-service-inquiry-open]');
const opener = page.locator('[data-service-inquiry-trigger]');
assert.equal(await opener.count(), 1, expected.route);
assert.equal(await iframe.getAttribute('src'), null, expected.route + ' must defer the iframe source');
assert.equal(await iframe.getAttribute('data-jotform-iframe-url'), url, expected.route);
@@ -121,7 +122,7 @@ try {
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('[data-service-inquiry-drawer]')?.open);
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('data-service-inquiry-open')), '', expected.route + ' restores focus');
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('data-service-inquiry-trigger')), expected.route.split('/').at(-1), expected.route + ' restores focus');
await opener.click();
await page.waitForTimeout(20);
assert.equal(await page.locator('[data-jotform-form-a] iframe').count(), 1, expected.route + ' must retain one iframe');
@@ -193,7 +194,7 @@ try {
window.setTimeout = (callback, timeout, ...args) => original(callback, timeout === 15000 ? 80 : timeout, ...args);
});
await fallbackPage.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded', timeout: 15000 });
await fallbackPage.locator('[data-service-inquiry-open]').click();
await fallbackPage.locator('[data-service-inquiry-trigger]').click();
const fallback = fallbackPage.locator('[data-jotform-iframe-fallback]');
await fallbackPage.waitForFunction(() => document.querySelector('[data-jotform-iframe-fallback]')?.hidden === false);
assert.equal(await fallback.isVisible(), true, 'blocked drawer request exposes the hosted fallback after timeout');
+4 -3
View File
@@ -155,6 +155,7 @@ test('consultation is isolated-test-only and requires its additional explicit ap
assert.equal(resolveFormAIframe(consultation({ isolatedTestApproved: 'false' })).reason, 'isolated-test-not-approved');
assert.equal(resolveFormAIframe(consultation({ environmentRole: 'staging' })).reason, 'invalid-environment-role');
assert.equal(resolveFormAIframe(consultation({ allowIndexing: 'true' })).reason, 'indexing-enabled');
for (const value of [undefined, '', 'invalid']) assert.equal(resolveFormAIframe(consultation({ allowIndexing: value })).enabled, false);
assert.equal(resolveFormAIframe(consultation({ route: '/es/aba-therapy', locale: 'en' })).reason, 'locale-not-authorized');
assert.equal(resolveFormAIframe(production({ route: '/client-consultation' })).reason, 'route-not-authorized');
assert.equal(resolveFormAIframe(production({ route: '/client-consultation', consultationTestApproved: 'true' })).reason, 'route-not-authorized');
@@ -192,10 +193,10 @@ test('the adapter preserves seven production routes and supports the separate bi
assert.match(shell, /PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED/);
assert.match(shell, /JotformIframe/);
assert.match(drawer, /lang: SupportedLocale/);
assert.match(drawer, /data-service-inquiry-open/);
assert.match(drawer, /data-explicit-activation/);
assert.match(drawer, /data-consultation-test/);
assert.match(drawer, /serviceCopy.heading/);
assert.match(drawer, /deferUntilActivated/);
assert.match(drawer, /aia:jotform-activate/);
assert.match(fs.readFileSync('src/scripts/service-inquiry.ts', 'utf8'), /aia:jotform-activate/);
assert.match(drawer, /Cerrar el formulario de consulta/);
assert.doesNotMatch(drawer, /No WAITLIST!|guarantee your child|now open for enrollment/i);
assert.match(layout, /entry\.data\.lang === 'en' \|\| entry\.data\.lang === 'es'/);
+2
View File
@@ -133,6 +133,7 @@ export async function verifyArtifact(root, mode = 'staging') {
}
});
const adapters = formAAdapters(html);
if (/<script[^>]*data-portal-payload[^>]*>\s*(?!null\s*<)[\s\S]*?\"streams\"[\s\S]*?<\/script>/i.test(html)) errors.push(`${route}: private portal ciphertext is not an approved release artifact`);
if (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`);
@@ -191,6 +192,7 @@ export async function verifyArtifact(root, mode = 'staging') {
const digest = createHash('sha256');
for (const file of files) {
const relative = path.relative(root, file).replaceAll(path.sep, '/');
if (/(?:^|\/)(?:private|hubfs)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
const contents = await fs.readFile(file, 'utf8');
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
+11
View File
@@ -129,3 +129,14 @@ test('artifact digest is deterministic for unchanged files', async () => {
assert.equal(first.artifactSha256, second.artifactSha256);
assert.equal(first.artifactFiles, second.artifactFiles);
});
test('release verification rejects private portal resources and encrypted payloads', async () => {
const root = await fixture('staging');
await fs.mkdir(path.join(root, 'private/employee-portal'), { recursive: true });
await fs.writeFile(path.join(root, 'private/employee-portal/manifest.json'), '{}');
await fs.appendFile(path.join(root, 'index.html'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('private portal ciphertext')));
assert.ok(result.errors.some((error) => error.includes('private/legacy')));
});
+90
View File
@@ -0,0 +1,90 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import * as cheerio from 'cheerio';
export const sourceOrigin = 'https://www.azinstitute4autism.com';
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
export function extractPayload(source) {
const $ = cheerio.load(source);
return JSON.parse($('script[type="application/json"]').filter((_, el) => {
try { return Array.isArray(JSON.parse($(el).text()).streams); } catch { return false; }
}).first().text());
}
export function resourceReferences(html, view) {
const $ = cheerio.load(html, {}, false), references = [];
const record = (value, label, attribute) => {
let url;
try { url = new URL(value, sourceOrigin); } catch { return; }
if (url.origin !== sourceOrigin || !url.pathname.startsWith('/hubfs/')) return;
url.hash = '';
references.push({ original: value, sourceUrl: url.href, originalPath: url.pathname, label, attribute, view });
};
$('[href], [src], [srcset]').each((_, el) => {
for (const attr of ['href', 'src']) if ($(el).attr(attr)) record($(el).attr(attr), $(el).text().trim() || $(el).attr('alt') || '', attr);
for (const value of ($(el).attr('srcset') || '').split(',').filter(Boolean)) record(value.trim().split(/\s+/)[0], $(el).attr('alt') || '', 'srcset');
});
// Unknown CSS resource dependencies must not be silently considered migrated.
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
return references;
}
export function rewriteResources(html, records) {
const mappings = new Map();
for (const record of records) {
if (!record.rewriteApproved) continue;
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping');
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping');
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control');
for (const ref of record.references) {
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
mappings.set(ref.original, record.deliveryPath);
}
}
let count = 0;
// Attribute-only replacement preserves all unrelated original bytes and labels.
const rewritten = html.replace(/\b(href|src)\s*=\s*(["'])(.*?)\2/gi, (whole, attr, quote, value) => {
const decoded = cheerio.load(`<a href=${quote}${value}${quote}></a>`)('a').attr('href');
const target = mappings.get(decoded);
if (!target) return whole;
count++; return `${attr}=${quote}${target}${quote}`;
});
return { html: rewritten, count };
}
export async function collectResource(record, directory, approvedOrigins = [sourceOrigin], fetcher = fetch) {
const limit = 50 * 1024 * 1024;
const name = sha256(record.sourceUrl);
const destination = path.join(directory, name);
if (record.sha256 && record.localFile === name) {
try {
const bytes = await fs.readFile(destination);
if (sha256(bytes) === record.sha256 && bytes.length === record.size) return { ...record, downloadStatus: 'verified-existing' };
} catch { /* Retry missing/invalid local copy without overwriting it. */ }
}
let url = new URL(record.sourceUrl), response;
for (let redirects = 0; redirects <= 4; redirects++) {
if (url.protocol !== 'https:' || url.username || url.password || !approvedOrigins.includes(url.origin) || !url.pathname.startsWith('/hubfs/')) throw new Error('Unapproved resource destination');
response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(20000), headers: { Accept: '*/*' } });
if ([301,302,303,307,308].includes(response.status)) {
await response.body?.cancel();
const location = response.headers.get('location');
if (!location || redirects === 4) throw new Error('Resource redirect limit');
url = new URL(location, url); continue;
}
break;
}
if (!response?.ok || Number(response.headers.get('content-length') || 0) > limit) { await response?.body?.cancel(); throw new Error('Resource HTTP/size failure'); }
const mime = (response.headers.get('content-type') || '').split(';')[0].toLowerCase();
const chunks = []; let size = 0;
const reader = response.body.getReader();
// Fetch timeout remains active through body streaming.
try { while (true) { const next = await reader.read(); if (next.done) break; size += next.value.length; if (size > limit) throw new Error('Resource size limit'); chunks.push(next.value); } }
catch (error) { await reader.cancel(); throw error; }
const bytes = Buffer.concat(chunks);
if (!size || /(?:html|javascript)/i.test(mime) || /^\s*(?:<!doctype html|<html|<script)/i.test(bytes.subarray(0, 512).toString())) throw new Error('Resource is empty or executable/error HTML');
if ((mime === 'application/pdf' || /\.pdf$/i.test(url.pathname)) && !bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw new Error('Invalid PDF signature');
if (mime === 'image/png' && bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') throw new Error('Invalid PNG signature');
if (mime === 'image/jpeg' && bytes.subarray(0, 3).toString('hex') !== 'ffd8ff') throw new Error('Invalid JPEG signature');
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 });
return { ...record, localFile: name, sha256: sha256(bytes), size, mime, downloadStatus: 'downloaded', verificationStatus: 'size-hash-and-applicable-signature-checked' };
}
+6 -6
View File
@@ -542,12 +542,12 @@ test('Phase 5A retains independently supported program ages, ratios and payment
test('Phase 5A conversion CTAs resolve to the intended published local route or form anchor', () => {
const cases = [
['/aba-therapy', 'Make an Appointment', '/client-consultation'],
['/autism-evaluations', 'Make an Appointment', '/client-consultation'],
['/learner-social-club', 'Enroll Now', '/client-consultation'],
['/es/aba-therapy', 'Hacer una cita', '/es/client-consultation'],
['/es/autism-evaluations', 'Programar una cita', '/es/client-consultation'],
['/es/learner-social-club', 'Enroll Now', '/es/client-consultation'],
['/aba-therapy', 'Request a Consultation', '/client-consultation'],
['/autism-evaluations', 'Request a Consultation', '/client-consultation'],
['/learner-social-club', 'Request a Consultation', '/client-consultation'],
['/es/aba-therapy', 'Solicitar una consulta', '/es/client-consultation'],
['/es/autism-evaluations', 'Solicitar una consulta', '/es/client-consultation'],
['/es/learner-social-club', 'Solicitar una consulta', '/es/client-consultation'],
['/referrals', 'Refer a Client', '#form-title'],
['/aba-therapy-intake-process', 'Take the First Step', '/client-consultation'],
['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'],
+247
View File
@@ -0,0 +1,247 @@
/**
* Guarded consultation-only Form A browser regression.
* Run after an explicitly gated isolated-test build. Every Jotform request is
* fulfilled or aborted locally; no Jotform request is transmitted.
*/
import { randomBytes } from 'node:crypto';
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
: 'playwright';
const output = await fs.mkdtemp('/tmp/aia-service-portal-');
const routes = [
{ route: '/client-consultation', locale: 'en', drawer: false, screenshot: 'client-consultation' },
{ route: '/es/client-consultation', locale: 'es', drawer: false, screenshot: 'es-client-consultation' },
{ route: '/schedule-consultation', locale: 'en', drawer: false, screenshot: 'schedule-consultation' },
{ route: '/aba-therapy', locale: 'en', drawer: true, screenshot: 'aba-therapy' },
{ route: '/autism-evaluations', locale: 'en', drawer: true, screenshot: 'autism-evaluations' },
{ route: '/learner-social-club', locale: 'en', drawer: true, screenshot: 'learner-social-club' },
{ route: '/es/aba-therapy', locale: 'es', drawer: true, screenshot: 'es-aba-therapy' },
{ route: '/es/autism-evaluations', locale: 'es', drawer: true, screenshot: 'es-autism-evaluations' },
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
];
const widths = [320, 390, 768, 1440];
const evidence = { routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' };
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
await new Promise((resolve) => reserve.close(resolve));
const origin = 'http://127.0.0.1:' + port;
let browser;
let preview;
function expectedUrl(locale) {
return 'https://form.jotform.com/262946078314059?formContext=consultation&language=' + locale;
}
try {
const { chromium } = await import(moduleName);
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
: { headless: true });
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' },
stdio: 'ignore',
});
const reachable = () => new Promise((resolve) => {
const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); });
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
for (let i = 0; i < 60 && !(await reachable()); i++) await delay(250);
assert.equal(await reachable(), true, 'Loopback preview did not become reachable');
const key = 'aia:service-inquiry:auto-suppressed-until';
const duration = 30 * 86400000;
const staffCode = randomBytes(24).toString('hex');
const managementCode = randomBytes(24).toString('hex');
const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">', staffCode),
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>', managementCode),
] };
const guard = async (context, inject = false) => {
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
if (inject && url.pathname === '/employee-portal') {
const response = await route.fetch();
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
return route.fulfill({ response, body: html });
}
return route.continue();
}
evidence.blocked.push({ method: req.method(), origin: url.origin, path: url.pathname });
if (url.origin === 'https://form.jotform.com') {
evidence.jotformAttempts.push({ method: req.method(), url: url.href });
return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Local fixture</title><label>Fixture contact <input></label><button type="button">Fixture Submit</button>' });
}
return route.abort();
});
};
if (!process.argv.includes('--portal-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' });
await guard(context);
const page = await context.newPage();
await page.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await page.clock.pauseAt(new Date('2026-10-29T12:00:01Z'));
await page.goto(origin + target.route, { waitUntil: 'domcontentloaded' });
const dialog = page.locator('[data-service-inquiry-drawer]');
const frame = dialog.locator('iframe');
const before = evidence.jotformAttempts.length;
assert.equal(await frame.getAttribute('src'), null);
await page.clock.runFor(3999);
assert.equal(await dialog.evaluate((d) => d.open), false);
await page.clock.runFor(100);
assert.equal(await dialog.evaluate((d) => d.open), true);
assert.equal(await frame.getAttribute('src'), expectedUrl(target.locale));
await page.waitForFunction(() => document.querySelector('iframe[data-jotform-iframe-frame]')?.src.includes('consultation'));
assert.equal(await page.locator('[data-service-inquiry-trigger]').count(), 1);
const heading = await dialog.locator('h2').textContent();
assert.ok(target.locale === 'es' ? /Conozca|preguntas/.test(heading) : /Explore|Questions/.test(heading));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(output, target.screenshot + '-' + width + '.png'), fullPage: true });
evidence.screenshots.push(target.screenshot + '-' + width + '.png');
await page.keyboard.press('Escape');
await page.clock.runFor(100);
assert.equal(await dialog.evaluate((d) => d.open), false);
const expiry = await page.evaluate((k) => Number(localStorage.getItem(k)), key);
const now = await page.evaluate(() => Date.now());
assert.ok(expiry > now && expiry <= now + duration);
await page.clock.runFor(10000);
await page.locator('[data-service-inquiry-trigger]').focus();
await page.keyboard.press('Enter');
await page.clock.runFor(100);
assert.equal(await dialog.evaluate((d) => d.open), true);
assert.equal(await frame.count(), 1);
await page.locator('[data-service-inquiry-close]').click();
await page.clock.runFor(100);
assert.equal(await page.evaluate(() => document.activeElement?.hasAttribute('data-service-inquiry-trigger')), true);
const renewed = await page.evaluate((k) => Number(localStorage.getItem(k)), key);
assert.ok(renewed > expiry);
assert.ok(evidence.jotformAttempts.length <= before + 1);
evidence.checks++;
await context.close();
}
}
// Scroll wins, suppression crosses locale/service navigation, manual opening bypasses it.
const context = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' });
await guard(context);
const page = await context.newPage(); await page.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await page.clock.pauseAt(new Date('2026-10-29T12:00:01Z'));
await page.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => scrollTo({ top: (document.documentElement.scrollHeight - innerHeight) * .6, behavior: 'instant' }));
await page.clock.runFor(100);
assert.equal(await page.locator('dialog').evaluate((d) => d.open), true);
await page.keyboard.press('Escape'); await page.clock.runFor(100);
await page.goto(origin + '/es/learner-social-club', { waitUntil: 'domcontentloaded' });
await page.clock.runFor(5000);
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' })); await page.clock.runFor(100);
assert.equal(await page.locator('dialog').evaluate((d) => d.open), false);
assert.equal(await page.locator('dialog iframe').getAttribute('src'), null);
await page.locator('[data-service-inquiry-trigger]').click(); await page.clock.runFor(100);
assert.equal(await page.locator('dialog').evaluate((d) => d.open), true);
await page.keyboard.press('Escape'); await page.clock.runFor(100);
// A second page-load initialization disposes previous listeners without duplicating activation.
await page.evaluate(() => document.dispatchEvent(new Event('astro:page-load')));
await page.locator('[data-service-inquiry-trigger]').click(); await page.clock.runFor(100);
assert.equal(await page.locator('dialog iframe').count(), 1);
evidence.checks += 2;
await context.close();
for (const preference of ['expired', 'invalid', 'denied', 'editing', 'short', 'other-modal']) {
const ctx = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' }); await guard(ctx);
await ctx.addInitScript(({ preference, key }) => {
if (preference === 'expired') localStorage.setItem(key, '1');
if (preference === 'invalid') localStorage.setItem(key, 'broken');
if (preference === 'denied') Object.defineProperty(window, 'localStorage', { get() { throw new Error('Denied'); } });
}, { preference, key });
const p = await ctx.newPage(); await p.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await p.clock.pauseAt(new Date('2026-10-29T12:00:01Z')); await p.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
if (preference === 'editing') await p.evaluate(() => { const field = document.createElement('input'); document.body.prepend(field); field.focus(); });
if (preference === 'other-modal') await p.evaluate(() => { const d = document.createElement('dialog'); document.body.append(d); d.showModal(); });
if (preference === 'short') {
await p.evaluate(() => { document.querySelectorAll('main > :not([data-service-inquiry-drawer]), header, footer').forEach((e) => e instanceof HTMLElement && (e.style.display = 'none')); });
await p.clock.runFor(100);
assert.equal(await p.locator('[data-service-inquiry-drawer]').evaluate((d) => d.open), false);
}
await p.clock.runFor(4100);
assert.equal(await p.locator('[data-service-inquiry-drawer]').evaluate((d) => d.open), !['editing', 'other-modal'].includes(preference), preference);
evidence.checks++; await ctx.close();
}
const backContext = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' });
await guard(backContext);
const backPage = await backContext.newPage();
await backPage.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
await backPage.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});
assert.equal(await backPage.evaluate((storageKey) => localStorage.getItem(storageKey), key), null);
await backPage.locator('[data-service-inquiry-trigger]').click();
assert.equal(await backPage.locator('dialog iframe').getAttribute('src'), expectedUrl('en'));
evidence.checks++;
await backContext.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);
const p = await ctx.newPage(); await p.goto(origin + '/employee-portal');
assert.equal(await p.locator('meta[name="robots"]').getAttribute('content'), 'noindex,nofollow');
assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
const input = p.locator('[data-portal-code]');
await input.fill('synthetic-incorrect'); await input.press('Enter');
await p.waitForFunction(() => document.querySelector('[data-portal-feedback]')?.textContent === 'Invalid Code');
assert.equal(await p.locator('[data-portal-content]').textContent(), '');
await input.fill(staffCode); await input.press('Enter');
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/);
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined);
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(), '');
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');
await p.reload(); assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
await input.fill(managementCode); await input.press('Enter');
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/);
evidence.checks++; await ctx.close();
}
for (const js of [false, true]) {
const ctx = await browser.newContext({ javaScriptEnabled: js, serviceWorkers: 'block' }); await guard(ctx);
const p = await ctx.newPage(); await p.goto(origin + '/employee-portal');
assert.equal(await p.locator('[data-portal-code]').isDisabled(), true);
assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
if (!js) assert.ok(await p.locator('noscript').last().isVisible());
evidence.checks++; await ctx.close();
}
assert.equal(evidence.blocked.filter((r) => !['GET','HEAD'].includes(r.method)).length, 0);
evidence.status = 'passed';
} catch (error) {
evidence.error = error instanceof Error ? error.message : String(error);
throw error;
} finally {
await fs.writeFile(path.join(output, 'evidence.json'), JSON.stringify(evidence, null, 2));
if (preview && preview.exitCode === null) {
preview.kill('SIGTERM');
await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]);
if (preview.exitCode === null) preview.kill('SIGKILL');
}
await browser?.close();
console.log(JSON.stringify({ ...evidence, artifactDirectory: output }, null, 2));
}
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomBytes, pbkdf2Sync, createCipheriv, createHmac } from 'node:crypto';
import { isSuppressed, reachedHalf, suppressionDuration } from '../src/utils/drawer-preference.ts';
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';
test('global preference validates expiry, corrupted values and clock anomalies', () => {
const now = 10000000000;
for (const raw of [null, '', 'NaN', '-1', '1.5', 'Infinity', String(now), String(now - 1), String(now + suppressionDuration + 1)]) assert.equal(isSuppressed(raw, now), false);
assert.equal(isSuppressed(String(now + suppressionDuration), now), true);
assert.equal(isSuppressed(String(now + 1), now), true);
});
test('half-scroll handles short, restored, resized and overscrolled pages', () => {
assert.equal(reachedHalf(0, 500, 800), false);
assert.equal(reachedHalf(100, 800, 800), false);
assert.equal(reachedHalf(499, 1800, 800), false);
assert.equal(reachedHalf(500, 1800, 800), true);
assert.equal(reachedHalf(-10, 1800, 800), false);
assert.equal(reachedHalf(500, 2800, 800), false);
});
test('four service configurations are bilingual without adding an Academy route', async () => {
assert.equal(Object.keys(serviceInquiryCopy).length, 4);
for (const variants of Object.values(serviceInquiryCopy)) for (const copy of Object.values(variants)) {
assert.ok(copy.heading && copy.introduction); assert.ok(!copy.introduction.includes('—'));
}
const { formAConsultationTestPlacements } = await import('../src/utils/form-integration.ts');
assert.equal(formAConsultationTestPlacements.length, 9);
assert.ok(!formAConsultationTestPlacements.some((p) => p.route.includes('preparatory')));
});
test('legacy independent crypto fixture and two-view separation round-trip', async () => {
const pass = randomBytes(32).toString('base64'), other = randomBytes(32).toString('base64');
const salt = randomBytes(16), iv = randomBytes(16), keys = pbkdf2Sync(pass, salt, 200000, 64, 'sha256');
const cipher = createCipheriv('aes-256-cbc', keys.subarray(0, 32), iv);
const ct = Buffer.concat([cipher.update('<h2>Synthetic staff</h2>'), cipher.final()]);
const mac = createHmac('sha256', keys.subarray(32)).update(Buffer.concat([iv, ct])).digest();
const stream = { id: 'synthetic-staff', iter: 200000, salt: salt.toString('base64'), iv: iv.toString('base64'), hmac: mac.toString('base64'), ct: ct.toString('base64') };
const second = await encryptPortalStream('synthetic-management', '<h2>Synthetic management</h2>', other);
validatePortalPayload({ alg: portalAlgorithm, streams: [stream, second] });
assert.equal(await decryptPortalStream(stream, pass), '<h2>Synthetic staff</h2>');
assert.equal(await decryptPortalStream(second, pass), null);
assert.equal(await decryptPortalStream(stream, other), null);
assert.equal(await decryptPortalStream(second, other), '<h2>Synthetic management</h2>');
assert.equal(await decryptPortalStream({ ...stream, hmac: randomBytes(32).toString('base64') }, pass), null);
assert.throws(() => validatePortalPayload({ alg: portalAlgorithm, streams: [stream, stream] }));
});
test('resource extraction reconciles duplicate views and rewrites only approved implemented links', () => {
const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>';
const refs = resourceReferences(html, 'synthetic');
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
assert.throws(() => rewriteResources(html, [record]));
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]);
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
assert.equal(rewriteResources(html, []).html, html);
});
test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
try {
const resource = { sourceUrl: 'https://www.azinstitute4autism.com/hubfs/synthetic.pdf' };
const good = await collectResource(resource, dir, undefined, async () => new Response('%PDF-1.7\nSynthetic', { headers: { 'content-type': 'application/pdf' } }));
assert.equal(good.downloadStatus, 'downloaded');
const again = await collectResource(good, dir, undefined, () => { throw new Error('must not fetch'); });
assert.equal(again.downloadStatus, 'verified-existing');
await assert.rejects(collectResource(resource, dir, undefined, async () => new Response('<html>Error</html>', { headers: { 'content-type': 'application/pdf' } })));
await assert.rejects(collectResource(resource, dir, undefined, async () => new Response('', { status: 302, headers: { location: 'https://unapproved.invalid/hubfs/file' } })));
} finally { await fs.rm(dir, { recursive: true }); }
});
test('portal input requires every local gate and rejects missing or malformed ciphertext', async () => {
const { readPortalInput } = await import('../src/utils/portal-input.mjs');
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-'));
try {
const pass = randomBytes(24).toString('base64');
const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '<p>Synthetic</p>', pass)));
const file = path.join(dir, 'fixture.json'); await fs.writeFile(file, JSON.stringify({ alg: portalAlgorithm, streams }), { mode: 0o600 });
const options = { approved: 'true', indexing: 'false', environment: 'isolated-test', file };
assert.ok(readPortalInput(options));
for (const changed of [{ approved: undefined }, { approved: 'false' }, { indexing: 'true' }, { indexing: undefined }, { environment: 'production' }, { file: '/missing-fixture' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), undefined);
} finally { await fs.rm(dir, { recursive: true }); }
});