Author SHA1 Message Date
DeCentN2Madness 36d417a422 fix(deploy): make portal installer runtime portable 2026-11-05 00:40:18 -07:00
DeCentN2Madness 4987f5ee1a fix(deploy): isolate portal gate from fixture validation 2026-11-05 00:40:18 -07:00
DeCentN2Madness 362b984753 fix(deploy): detect SSH tooling without connecting 2026-11-05 00:40:18 -07:00
DeCentN2Madness ba93a54942 feat(deploy): add Contabo release workflow and portal payload gate 2026-11-05 00:40:18 -07:00
DeCentN2Madness 5fa54719b2 docs(nginx): import HubSpot redirect rules 2026-11-02 00:01:38 -07:00
DeCentN2Madness e224616b99 fix(forms): accept HIPAA Jotform completion origin 2026-11-01 19:16:19 -07:00
DeCentN2Madness a72470459a docs(referrals): record owner-approved existing Jotform cutover workflow 2026-11-01 18:10:57 -07:00
DeCentN2Madness 80e0c405bc docs(referrals): retire Form C plan and document existing hosted workflow 2026-11-01 18:10:54 -07:00
DeCentN2Madness 5c8ba2e11e fix(referrals): restore existing hosted referral CTA and remove native placeholder 2026-11-01 18:10:51 -07:00
DeCentN2Madness 85def8466c refactor(maps): share localized OpenStreetMap component 2026-11-01 17:48:38 -07:00
DeCentN2Madness b5e606a839 fix(tour): restore location map and schedule button 2026-11-01 16:41:20 -07:00
DeCentN2Madness 8dff6a5ad0 style(portal): refine resource links, list markers and focus styling
Remove forced link underlines and suppress bullets and numbering only on
staff lists whose items contain resource buttons. Keep the responsive
Management Templates grid while allowing its buttons to use their natural
size instead of stretching to fill each cell.

Remove the outline around the unlocked content container while preserving
programmatic focus and visible keyboard focus on inputs and resource links.
Update the presentation regression assertions for these styling choices.

Validation: Astro build (128 pages), link audit (zero broken links), and
10 focused service/portal tests pass.
2026-11-01 14:45:13 -07:00
42 changed files with 1288 additions and 143 deletions

No files matched your search

+4 -1
View File
@@ -10,7 +10,10 @@ PUBLIC_AIA_FORMS_MOCK_RESULT=success
PUBLIC_AIA_FORM_A_PREVIEW=false
PUBLIC_ALLOW_INDEXING=false
# Employee portal ciphertext is an opt-in local compatibility preview only.
# Employee portal ciphertext is a non-PUBLIC build-only input.
# 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
# Explicit production-indexing input; it is never placed in Git or source snapshots.
AIA_PORTAL_PRODUCTION_APPROVED=false
# AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json
+20
View File
@@ -580,6 +580,15 @@ npm run audit:links
The live crawl is optional and may fail in restricted network environments.
## Referral CTA
The public `/referrals` page preserves the existing site's **Refer a Client**
button, linking directly to https://form.jotform.com/231638510219149. The
site does **not** embed a referral form or require Form C. This destination is
separate from Form A's 16 allowlisted placements. Legacy-hosted-form privacy,
recipient, accessibility and operational acceptance remain the owner's release
responsibility.
## Forms
As of the owner decision on 2026-10-30, `npm run dev` and `npm run build`
@@ -602,6 +611,17 @@ Verify matching output with
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
for those explicit builds. No actual submissions run in automated tests.
## Static releases and employee portal
The Contabo deployment interface is intentionally separate from NixOS changes.
Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`,
dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. The server-side installer is a root-only
POSIX shell script using NixOS base tools; it does not require Node.js on
Contabo. Ordinary releases never copy employee documents into `dist`;
protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is
explicitly non-PHI.
### Google Ads consultation-form conversion handoff
The Google Ads conversion ID, label, and privacy-gated Astro integration
+20 -4
View File
@@ -1,9 +1,25 @@
# 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.
Recorded 2026-11-04. The clinical director accepted the collected, non-PHI
staff and management portal content and the interim shared-code presentation.
Browser-side decryption remains access friction rather than individual
authentication, revocation, a secure session, or a HIPAA determination.
## Approved production ciphertext
An indexing-enabled production build may include one owner-approved encrypted
two-stream payload when both of these non-public build inputs are set:
```sh
AIA_PORTAL_PRODUCTION_APPROVED=true
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json
```
The build rejects missing, malformed, symlinked, oversized, wrong-mode, or
simultaneous local-preview/production inputs. It never decrypts the payload at
build time. `/employee-portal` remains noindex, sitemap-excluded and
hreflang-excluded. See [the deployment runbook](../nixos/deploy.md) for the
approved release and protected-resource installation commands.
## Source and private collection
+15 -3
View File
@@ -57,8 +57,11 @@ Every URL has exactly `formContext` and `language` parameters:
| consultation | `/client-consultation` (en), `/es/client-consultation` (es), `/schedule-consultation` (en) | Immediate, 3 |
| consultation | `/aba-therapy`, `/autism-evaluations`, `/learner-social-club` (en), and their `/es/` counterparts (es) | Deferred, 6 |
The context is presentation-only. Physical Form B is retired. Form C and
`/referrals` remain inactive; other routes cannot acquire Form A by setting a mode.
The context is presentation-only. Physical Form B is retired. No Form C is planned.
`/referrals` independently links to the existing hosted Jotform
`https://form.jotform.com/231638510219149`, not to Form A. That link is
owner-directed but its legacy form's runtime privacy/recipient acceptance remains
separate from Form A. Other routes cannot acquire Form A by setting a mode.
The typed future Academy service copy does not publish another page.
Live service drawers open at four seconds OR 50% scroll, canceling the unused
@@ -110,6 +113,15 @@ node tools/phase10-artifact-verify.mjs --mode=indexing --forms=live
Employee-portal ciphertext remains separately gated by its own approval, private
input and non-indexing preview requirement. Form A modes cannot authorize it.
## Existing professional referral destination (owner decision 2026-11-01)
`/referrals` has one `Refer a Client` link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). It does not embed Form A
or use Form C. No new form, question, Jotform integration, query parameter or
notification routing is introduced. Test the link and verify the separate
existing form's privacy, field necessity, recipients and confirmation with its
account owner before release.
## Form context and button labels
The current Form A design uses a hidden `formContext` value with fixed, approved values such as `contact` and `landing`. Jotform does not provide a native conditional rule that changes one Submit button's accessible label safely in every context. If owner-approved context-specific labels are required, use multiple native Jotform Submit buttons and Jotform's conditional visibility rules:
@@ -130,7 +142,7 @@ cross-origin iframe.
- [x] Direct English/Spanish Form A language handling, conditional email requirement, synthetic submission and native thank-you were owner-verified; this is not embedded-iframe acceptance.
- [x] Direct staff-notification/autoresponder delivery, generic no-answer/no-PDF content, fixed Reply-To, recipient access/retention, and absent webhooks/integrations were owner-verified for Form A.
- [ ] Exact remaining Form A consultation question properties, choices, conditional routing and availability, plus future Form C configuration, are approved.
- [ ] Exact remaining Form A consultation question properties, choices, conditional routing and availability, and consultation language/configuration, are approved.
- [ ] English and Spanish embedded-iframe labels, validation, confirmation, and button variants are reviewed by fluent-language owners.
- [ ] The hidden honeypot remains present, effective, and excluded from user-facing visual changes.
- [ ] Exactly one native Submit button is visible for every valid context.
+7 -7
View File
@@ -63,10 +63,10 @@ Test direct requests, alternate encodings, role crossover and rollback.
- 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.
The approved production portal build uses a non-public encrypted two-stream
payload and retains the same browser-side decryption boundary. Approved non-PHI
documents install separately into the VPS-owned staff/management directories;
they are never copied into `dist`. Verify both protected namespaces, two-view
separation, artifact exclusions, code rotation, restore and rollback before
cutover. Revoking a static release cannot retract ciphertext or files already
downloaded by visitors. Site cutover remains separately authorized.
+89
View File
@@ -0,0 +1,89 @@
# Contabo release operations
This site publishes static content through the merged Contabo release manager.
It does not run `nixos-rebuild`, reload nginx, change DNS, or write directly to
`/srv/www/azinstitute4autism.com/www/dist`.
## Required local tools and private inputs
- Node.js 22+, Git, tar, SSH and rsync.
- A normal SSH identity accepted by the existing root receiver:
`AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`.
- `AIA_DEPLOY_HOST=administrator@host` (or the approved equivalent).
- For a real public release, an owner-controlled, regular encrypted payload:
`AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`.
The payload remains outside Git and the source snapshot. It is browser ciphertext
only; neither access code nor decrypted portal HTML enters an Astro environment
variable, public resource directory, build log, or deployment manifest.
## Commands
From `www/`:
```sh
npm run deploy -- --dry-run
```
Dry-run performs the local production build, tests, artifact verification,
manifest creation and source-snapshot preparation. With no private production
payload it uses a generated synthetic two-stream fixture and explicitly reports
that the result is not production-ready. It never opens SSH or rsync.
```sh
AIA_DEPLOY_HOST='root@approved-host' \
AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE="$PWD/private/employee-portal/approved-production-ciphertext.json" \
npm run deploy -- --confirm
```
The confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and
counters off, then uploads only fresh commit-specific `staging/source-<sha>` and
`staging/dist-<sha>` directories. It runs, in order:
```text
aia-website-release verify
aia-website-release sync-source
aia-website-release promote
```
The VPS verifies the manifest, promotes an immutable release by atomic symlink,
and retains the prior release. A successful rsync transfer alone is not success.
Rollback requires the reviewed manifest retained at the server’s fixed path:
```sh
AIA_DEPLOY_HOST='root@approved-host' \
AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
```
## Employee resources: separate one-time operation
Approved non-PHI employee files are never part of an ordinary release. After
the website release that contains `portal-resource-install.sh` is promoted and
the VPS SOPS-backed Basic Auth material is independently ready:
```sh
npm run deploy:portal-resources -- --dry-run
AIA_DEPLOY_HOST='root@approved-host' \
AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:portal-resources -- --confirm
```
The installer invokes the promoted root-only `portal-resource-install.sh` through
NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally
installed Node.js runtime. It reads only the private collection manifest,
accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to
`/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and
uploads a root-controlled temporary tree. It never uses `--delete` for resource
directories and refuses to replace a different existing file. The VPS installs
regular files as `root:nginx` mode `0640` and reports metadata only.
Before public DNS cutover, test the site with approved host-header/SNI methods,
including the portal’s noindex response, valid ciphertext unlock, both protected
resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal
shared-code rotation is coordinated: update VPS SOPS inputs and privately
re-encrypt the browser payload together. This interim portal is non-PHI and is
not an employee identity system.
+3
View File
@@ -10,6 +10,9 @@
"check": "astro check",
"build": "astro check && astro build",
"build:sandbox": "NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro check && NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro build",
"deploy": "node tools/deploy.mjs",
"deploy:rollback": "node tools/deploy-rollback.mjs",
"deploy:portal-resources": "node tools/deploy-portal-resources.mjs",
"preview": "astro preview",
"extract:blog-footers": "node tools/extract-blog-footers.mjs",
"crawl:live": "node tools/crawl-live-site.mjs",
+41 -4
View File
@@ -1,6 +1,43 @@
# AIA Astro Migration — Living Session Handoff
**Last updated:** October 30, 2026 (UTC)
## Deployment and employee-portal release preparation — 2026-11-04
The clinical director has accepted the collected non-PHI employee portal views,
their interim shared-code presentation and the approved employee resources.
This supersedes earlier collection/content-acceptance tasks. Deployment,
filesystem permissions, protected-resource authorization, DNS and cutover remain
separate technical gates.
The site-side deployment workflow builds an indexing-enabled release with an
explicit non-public approved portal ciphertext input, validates the artifact and
Contabo-compatible manifest, uploads only commit-specific staging trees, then
uses `aia-website-release verify`, `sync-source`, and `promote`. It does not
rebuild NixOS or alter nginx. Employee documents are a separate owner-run,
hash-verified resource-installation operation into the VPS-owned protected
namespaces. See [the release runbook](../integrations/nixos/deploy.md).
The browser payload remains ciphertext only; codes, plaintext portal content,
private manifests and resources must never enter Git, `dist`, source snapshots,
logs or deployment manifests. `/employee-portal` remains noindex and excluded
from sitemap/hreflang. Form A, referral and publication behavior are unchanged.
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
**Last updated:** November 1, 2026 (UTC)
**Repository:** `AIAMarketing/azinstitute4autism.com`
**Working branch:** `faithful-astro-migration`
**Last pushed checkpoint:** `55adb797051473c3067a0d454515153237e9b8d6` — completion handling and portal styling. The new commit containing this handoff simplifies Form A to default-live without rewriting that history.
@@ -26,7 +63,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
origin, context and language. Forms B/C are not activated; physical B is retired.
origin, context and language. Form B is retired; Form C is canceled by the November 1 referral decision.
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for
@@ -213,10 +250,10 @@ trusted-source comparison. No insecure retrieval was repeated.
### Current two-form status — 2026-10-29
- Form A uses one 16-placement live registry: two contact, five landing, three standalone consultation and six EN/ES service drawers. Mode defaults to live; off/mock are explicit options. See the current decision above and the two-form specification.
- Form A uses one 16-placement live registry: two contact, five landing, three standalone consultation and six EN/ES service drawers. Mode defaults to live; off/mock are explicit options. `/referrals` independently links to the owner-approved existing hosted Jotform; Form C creation is canceled. See the November 1 decision above.
- Owner-confirmed direct-Jotform evidence covers synthetic EN/ES submission, native thank-you, conditional email/language, generic no-answer/no-PDF staff notification, autoresponder delivery, corrected fixed Reply-To, no webhooks/integrations, privacy scope, recipient access and retention. The owner also reports successful Astro-iframe synthetic submission and confirmation on English contact, Spanish contact and one ABA landing placement; record the exact third route before release. Functional submission acceptance is complete only for those owner-reported samples. All-sixteen-route accessibility, runtime-network/telemetry, deployment and site-release acceptance remain open.
- The adapter retains fixed URLs, responsive fixed heights, `no-referrer`, `allow-forms allow-scripts allow-same-origin`, no helper script, and a hosted fallback that is hidden without space until a 15-second missing-load timeout. A frame load only clears that timeout; it does not prove cross-origin application health or accepted submission. The official Jotform resizing handler is conditionally acceptable pending separately authorized source, origin, privilege, Firefox and error-state verification; it is not implemented. Fixed heights remain current.
- No additional Form A approval/environment flags remain. Indexing does not authorize or disable forms. Marketing-only notification routing remains a pre-cutover operational check. Rollback requires explicit mode `off`, rebuild and artifact verification; merely removing the mode restores live. Form C, counters, analytics and API forwarding remain inactive.
- No additional Form A approval/environment flags remain. Indexing does not authorize or disable forms. Marketing-only notification routing remains a pre-cutover operational check. Rollback requires explicit mode `off`, rebuild and artifact verification; merely removing the mode restores live. No Form C is needed. Counters, analytics and API forwarding remain inactive.
The indexing-enabled build has 127 HTML routes/canonicals and 109 sitemap URLs.
Normal staging has 128 `noindex,nofollow` pages because it also contains `/lp`;
+37
View File
@@ -4367,3 +4367,40 @@ because credentials were unavailable. No private markup or resources are tracked
The Spanish extra dropdown choices remain a Jotform Builder review item, with
fixed Astro context/locale URLs verified. See
[focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md).
## 2026-11-01 — Tour map and CTA restoration
Compared the public `/tour` HTML retrieved with normal TLS validation on
2026-11-01 against the migrated page. The source ends with a centered filled
“Schedule a Tour Today” link to `/client-consultation`, followed by a full-width
360px Leaflet 1.9.4 map. Restored the CTA using the existing AIA `Button`
component and added a Tour-only map below the content container.
The map preserves the source view `[33.63, -111.88756]`, zoom 12, clinic marker
`[33.617746, -111.88756]`, OpenStreetMap attribution, 124px logo and directions
popup. Leaflet CSS/JS use the same version and SRI hashes already used by Contact;
tile requests go to `tile.openstreetmap.org`. These are external map dependencies,
not self-hosted assets. No analytics, forms, clinical copy, metadata or routes
changed. The logo is local. Intentional accessibility/reliability improvements:
meaningful region/marker labels, visible link focus, safe new-tab links, correctly
encoded directions destination and address/directions fallback when JavaScript
or Leaflet is unavailable. Existing nine-photo gallery and captions are unchanged.
Validation: Astro check/build passed (128 pages); link audit found zero broken
internal links; five Phase 8 accessibility tests and the focused nine-photo Tour
regression passed. Chromium comparison against the retrieved live HTML and the
local build at 390px and 1440px passed: both maps span the viewport at 360px
height, all 4/12 requested map tiles loaded, marker/directions popup rendered,
and the Astro page had no horizontal overflow or JavaScript exceptions. The
CTA matches source fill/border `#ffbd59`, text `#254080` and no underline, using
the established Astro button proportions rather than copying HubSpot CSS.
No-JavaScript address/directions and consultation-link fallback passed.
Evidence and full-page/map screenshots: `/tmp/aia-tour-review-hknOnA/`.
Read-only map/static asset GETs were allowed; unrelated vendor scripts and
tracking requests were blocked. Consequently the live reference reported missing
jQuery errors, while the Astro page reported none; this was a bounded map/CTA
comparison, not validation of all live HubSpot functionality. An initial test
selector matched both directions and the popup-close link; narrowing it to the
external directions link resolved the test-only failure. No account changes,
submissions, deployment, staging or commit occurred.
+54
View File
@@ -12,6 +12,60 @@ location / {
try_files $uri $uri/index.html $uri.html =404;
}
## HubSpot redirect export imported from repository-root `url-redirects.csv`
## on 2026-11-01. The export contains 45 STANDARD 301 rules. The three aliases
## already generated below (`/aba`, `/autismevaluations`, and
## `/learnersocialclub`) are intentionally not duplicated here.
##
## The IndexNow verification-file redirect retains HubSpot's original external
## target. Before cutover, confirm that target remains reachable after this
## hostname points at nginx; serving the required verification file locally may
## be necessary instead.
rewrite ^/abaportal$ /employee-portal permanent;
rewrite ^/aiaportal$ /employee-portal permanent;
rewrite ^/giveback$ /donate-autism-giveback permanent;
rewrite ^/blog$ /library permanent;
rewrite ^/donate$ /donate-autism-giveback permanent;
rewrite ^/contactus$ /contact permanent;
rewrite ^/post/4waysonhowtoreducebehaviormanagement$ /library/reduce-behavior-management permanent;
rewrite ^/post/playandleisureskillsforchildrenwithautism$ /library/play-leisure-skills permanent;
rewrite ^/post/managing-aggressive-behavior-outbursts-and-proper-communication-with-your-child-during-those-moments$ /library/managing-aggressive-behavior-outbursts permanent;
rewrite ^/post/critical-thinking-skills-for-children-with-autism$ /library/critical-thinking-skills permanent;
rewrite ^/post/new-year-goals-for-our-children-with-autism$ /library/new-year-goals-2023 permanent;
rewrite ^/post/aia-world-autism-month-celebration$ /library/aia-world-autism-community-day-celebration permanent;
rewrite ^/forms$ /client-forms permanent;
rewrite ^/post/november-aia-update$ /library/november-2022-aia-update permanent;
rewrite ^/post/behavior-modification-techniques-reinforcement-theory$ /library/behavior-modification-techniques-reinforcement-theory permanent;
rewrite ^/post/navigating-the-autism-journey-understanding-and-supporting-our-children-with-autism$ /library/understanding-autism-support permanent;
rewrite ^/post/arizona-institute-for-autism-is-thrilled-to-announce-its-transformation-into-an-integrated-aba-model$ /library/integrated-aba-model-announcement permanent;
rewrite ^/post/repetitive-behaviors-with-children-diagnosed-with-autism$ /library/repetitive-behavior permanent;
rewrite ^/post/asd-sensory-friendly-thanksgiving-recipe$ /library/asd-sensory-friendly-recipe-thanksgiving-pumpkin-cake permanent;
rewrite ^/post/toilet-training-for-a-child-with-autism-using-applied-behavior-analysis-aba$ /library/aba-toilet-training-child-autism permanent;
rewrite ^/post/new-year-2024-holiday-and-autism$ /library/new-year-2024-autism-holiday-tips permanent;
rewrite ^/post/understanding-sensory-processing-in-the-context-of-aba-therapy$ /library/sensory-processing permanent;
rewrite ^/post/enhancing-generalization-for-broader-impact$ /library/enhancing-generalization-skills permanent;
rewrite ^/post/asd-sensory-friendly-recipes-pancakes$ /library/asd-sensory-friendly-recipes-pancakes permanent;
rewrite ^/post/the-benefits-of-aba-therapy-for-children-with-autism$ /library/aba-therapy-benefits permanent;
rewrite ^/post/meet-rula-diab-board-certified-behavior-analyst-bcba$ /library/meet-rula-diab-bcba permanent;
rewrite ^/post/integrity-empowerment-and-excellence-arizona-institute-for-autism-expands-to-new-office$ /library/new-aia-scottsdale-office permanent;
rewrite ^/post/community-highlights-meet-rula-diab-of-arizona-institute-for-autism$ /library/community-highlight-meet-rula-diab permanent;
rewrite ^/post/navigating-dtt-and-net-in-aba-therapy$ /library/aba-therapy-dtt-net-guide permanent;
rewrite ^/post/benefits-of-prompting-and-parenting-in-aba-therapy$ /library/aba-therapy-prompting-parenting-benefits permanent;
rewrite ^/post/building-rapport-with-a-child-in-aba-therapy-the-key-to-fun-engaging-and-effective-sessions$ /library/autism-therapy-rapport-strategies permanent;
rewrite ^/post/exploring-behavior-management-and-understanding-the-functions-of-behavior$ /library/behavior-management-functions-guide permanent;
rewrite ^/post/navigating-back-to-school-understanding-antecedent-and-consequence-interventions-for-families-with$ /library/aba-school-readiness-guide permanent;
rewrite ^/post/proactive-and-reactive-strategies-in-aba$ /library/proactive-reactive-aba-strategies-guide permanent;
rewrite ^/post/individualized-aba-therapy$ /library/individualized-aba-therapy permanent;
rewrite ^/post/parental-self-care-for-autism-families$ /library/autism-family-self-care-tips permanent;
rewrite ^/post/teaching-self-advocacy-through-aba-empowering-children-to-express-their-needs$ /library/autism-self-advocacy-skills-aba permanent;
rewrite ^/ar/library/author/rula-diab-ar$ https://www.azinstitute4autism.com/ar/library/author/rula-diab permanent;
rewrite ^/locations$ /contact permanent;
rewrite ^/schedule/consultation$ /schedule-consultation permanent;
rewrite ^/e2ae5722f9ae40e89a9ebb8d53200e66\.txt$ https://www.azinstitute4autism.com/hubfs/e2ae5722f9ae40e89a9ebb8d53200e66.txt permanent;
rewrite ^/parent-guide$ /library/parents-guide-to-autism-and-aba permanent;
## Existing generated migration redirects follow. They include the remaining
## three rules from the HubSpot export, plus current page-one aliases.
rewrite ^/aba$ /aba-therapy permanent;
rewrite ^/autismevaluations$ /autism-evaluations permanent;
rewrite ^/learnersocialclub$ /learner-social-club permanent;
@@ -1,5 +1,21 @@
# Phase 10 — Technical Readiness Package
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
Date: 2026-10-22 UTC
Status: Technical handoff prepared; production gates remain open.
@@ -17,7 +33,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
origin, context and language. Forms B/C are not activated; physical B is retired.
origin, context and language. Form B is retired; Form C creation was canceled by owner decision on 2026-11-01.
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for
@@ -85,7 +101,7 @@ The final normal staging build was restored after the indexing build. The explic
1. Record a locked Node/npm/toolchain and reproducible artifact manifest.
2. Obtain sanitized Contabo/NixOS/nginx/TLS/DNS/Cloudflare facts.
3. Verify host-local serving, canonical host, redirects, 404s, cache headers and rollback on an approved staging host.
4. Default-live Form A is owner-authorized on exactly 16 placements, including public development hosting. No approval flags remain; indexing is independent. Final accessibility, Spanish options/provider-event, runtime privacy and marketing-recipient operational review remain open. Form C remains unimplemented. Site cutover still needs release authorization.
4. Default-live Form A is owner-authorized on exactly 16 placements, including public development hosting. No approval flags remain; indexing is independent. Final accessibility, Spanish options/provider-event, runtime privacy and marketing-recipient operational review remain open. No Form C is required. `/referrals` links to the existing hosted Jotform; its separate privacy/recipient/operational review remains pending. Site cutover still needs release authorization.
5. Obtain P9-04 counter origin/credential/consent approval before a live adapter.
6. Resolve P9-05 analytics/advertising measurement and P9-07 privacy/legal approval.
7. Obtain P9-06 clinical/organizational and fluent-language approvals, including Arabic diagrams and Spanish HSA/FSA claims.
@@ -95,7 +111,7 @@ The final normal staging build was restored after the indexing build. The explic
## Decision status
P9-01 through P9-10 remain **Pending decision**. No technical feasibility result in this package is an approval. Default-live Form A is explicitly owner-authorized. Counters, analytics, advertising, Form C, private portal serving and hosting activation remain separate and unauthorized by this package.
P9-01 through P9-10 remain **Pending decision**. No technical feasibility result in this package is an approval. Default-live Form A is explicitly owner-authorized. Counters, analytics, advertising, The existing referral destination, private portal serving and hosting activation still require their respective acceptance; no Form C creation is planned.
## Recommended checkpoint boundaries
@@ -1,5 +1,12 @@
# Phase 9 — Decision register
> **2026-11-01 referral decision:** The `/referrals` CTA now uses the existing
> hosted Jotform at https://form.jotform.com/231638510219149. Do not create Form C.
> P9-03 remains open for review of that legacy destination's actual privacy,
> notification/recipient, clinical and operational configuration. Older Form C
> references below are historical and superseded.
>
Recorded: 2026-10-14 (source review began October 13). Prepared during Phase 7; no decision or integration is approved by this register. Owner roles are proposed responsibilities, not assigned individuals. Every item is **Pending decision**. A technical option is not approval; translation is not legal/clinical sign-off; browser acceptance is not deployment authorization.
## P9-01 — Landing-page ownership
@@ -35,14 +42,14 @@ Recorded: 2026-10-14 (source review began October 13). Prepared during Phase 7;
## P9-03 — Form/API ownership
- **Status:** Pending decision
- **Current implementation:** Form A has seven production-eligible placements and nine separately gated, isolated-test-only consultation placements; all other shared form controls remain disabled with no backend. Physical Form B is retired; Form C remains future work. The consultation surfaces are not production-authorized.
- **Current implementation (superseding 2026-11-01 decision):** Form A is live by default at its fixed 16 placements. `/referrals` renders a button linking to the existing hosted Jotform (https://form.jotform.com/231638510219149) without an inline form; the Form C plan is canceled.
- **Evidence:** [two-form integration specification](phase-9-two-form-integration-specification-2026-10-28.md), [../src/components/FormShell.astro](../src/components/FormShell.astro)
- **Options:** Approved direct Jotform Form A consultation and separate Form C; no Contabo backend for protected lead data.
- **Current direction:** Retain Form A for its 16 approved routes and the existing externally hosted Jotform for `/referrals`; no Form C creation or Contabo lead-form backend.
- **Risk if unresolved:** Lost inquiries, accidental collection, unsafe retention or ambiguous failure.
- **Decision owner role:** AIA intake/operations owner
- **Required reviewers:** Privacy/legal/security
- **Dependencies:** Field necessity, recipient, spam controls, retention/access, failure handling, P9-07/09
- **Acceptance criteria:** Synthetic nonproduction submission success/failure, no-JS/Enter behavior, access and retention documented; explicit activation approval.
- **Acceptance criteria:** Verify current referral button URL, hosted form accessibility, actual submission/recipient paths, clinical/privacy controls, retention, failure handling and continued availability; record production release decision.
- **Impacted routes/systems:** Contact, consultation, referrals, service drawer
- **Authorization required:** Explicit owner decision, then a bounded implementation/test scope; live activation/deployment requires separate authorization.
- **Resolution date/evidence:** None; not approved.
@@ -1,5 +1,21 @@
# P9-03 source-specific implementation plan
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
## Current decision — 2026-10-30: default-live Form A
The owner intentionally supersedes the earlier isolated-test and seven-route
@@ -14,7 +30,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
origin, context and language. Forms B/C are not activated; physical B is retired.
origin, context and language. Form B is retired; Form C planning is canceled as of 2026-11-01.
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for
@@ -32,7 +48,7 @@ telemetry/privacy, recipient-routing or hosting acceptance. See the
Earlier dated decisions and test results below are historical evidence, not
current environment setup instructions.
Date: **2026-10-18** · Plan version **1.9 revision 2026-10-30** · Governing contract: [P9-03-JF-2026-10-18 v1.9](phase-9-jotform-integration-contract-2026-10-18.md).
Date: **2026-10-18** · Plan version **2.0 revision 2026-11-01** · Governing contract: [P9-03-JF-2026-10-18 v1.9](phase-9-jotform-integration-contract-2026-10-18.md).
**Planning only. Do not execute any batch from this document without separate authorization.** Phase 9 establishes decisions and bounded implementation authority. Phase 10 retains final production integration acceptance, rollback and cutover. The user stages, signs and pushes.
@@ -59,7 +75,7 @@ The confirmation is limited. Recipient access and Form A retention/deletion cont
| F-003 | `/client-consultation` | `en/client-consultation.md` | `FormShell.astro` via PageLayout | Form A / `consultation` EN, isolated-test only |
| F-004 | `/es/client-consultation` | `es/client-consultation.md` | Same | Form A / `consultation` ES, isolated-test only |
| F-005 | `/schedule-consultation` | `en/schedule-consultation.mdx` | Same | Form A / `consultation` EN, isolated-test only |
| F-006 | `/referrals` | `en/referrals.mdx` | Same; preserve `#form-title` | C EN |
| F-006 | `/referrals` | `en/referrals.mdx` | Preserve the existing `Refer a Client` button, directly link https://form.jotform.com/231638510219149; no inline form | Existing hosted Jotform; no Form C |
| F-007 | `/aba-therapy` | `en/aba-therapy.mdx` | `ServiceInquiryDrawer.astro` | Form A / planned `consultation`; drawer redesign |
| F-008 | `/autism-evaluations` | `en/autism-evaluations.mdx` | Same | Form A / planned `consultation`; referral-assistance approval |
| F-009 | `/learner-social-club` | `en/learner-social-club.mdx` | Same | Form A / planned `consultation`; program approval |
@@ -156,7 +172,7 @@ The owner separately reports that the existing `AIA Lead Generator | plain` form
- **Acceptance:** distinguish static elements from fields; every proposed field mapped or explicitly pending; no guessed internal IDs or locale behavior. Required callback/contact expectations approved.
- **Stop:** before any form creation/editing, tests generating notifications, or production mapping.
### Batch B — Configure Form A consultation and create Form C
### Historical Batch B — Configure Form A consultation and create Form C (Form C canceled)
- **Objective:** configure Form A’s future `consultation` context without changing approved `contact`/`landing` behavior, and create one disabled blank Form C — Professional Referral Initiation.
- **Authorization:** explicit Jotform mutation permission naming the Form A context, Form C, account owner, reviewers, environment, isolation steps, proposed settings and stopping conditions. The historical `250727122848156` form is not a source and must not be cloned or repurposed.
@@ -1,5 +1,21 @@
# P9-03 Jotform integration contract
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
## Current decision — 2026-10-30: default-live Form A
The owner intentionally supersedes the earlier isolated-test and seven-route
@@ -14,7 +30,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
origin, context and language. Forms B/C are not activated; physical B is retired.
origin, context and language. Form B is retired and no Form C is planned as of 2026-11-01.
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for
@@ -32,9 +48,9 @@ telemetry/privacy, recipient-routing or hosting acceptance. See the
Earlier dated decisions and test results below are historical evidence, not
current environment setup instructions.
**Contract ID:** P9-03-JF-2026-10-18 · **Version:** 1.9 · **Decision revision:** 2026-10-30
**Contract ID:** P9-03-JF-2026-10-18 · **Version:** 2.0 · **Decision revision:** 2026-11-01
**Current status: default-live Form A approved on 16 placements; site deployment/cutover not authorized. Historical contract follows.**
**Current status: Form A approved on 16 placements; `/referrals` uses the existing hosted Jotform link, not a proposed Form C. Site cutover is not authorized. Historical contract follows.**
This is the authoritative P9-03 planning contract for the owner decisions supplied on October 18 and revised through October 29. It supersedes conflicting *proposals* in the [October 17 inventory](phase-9-form-entry-point-inventory-2026-10-17.md), without rewriting that historical record. It does not supersede project policy, approve clinical/legal content beyond the Form A configuration scope recorded below, close the Phase 9 register, or authorize production changes. Future changes require an explicit owner decision, a version/change record and evidence links. Version 1.0 records the original planning instruction; version 1.1 adds the owner-supplied complete roster; version 1.2 supersedes the copy-based Form B proposal with three new blank forms; version 1.3 records limited owner-confirmed Form A configuration evidence; version 1.4 reconciles completed direct-Jotform testing and the seven-route isolated iframe adapter; version 1.5 records owner-reported embedded synthetic acceptance for sampled routes; version 1.6 adds the independent production-build gate without deploying it; version 1.7 retires physical Form B and specifies Form A consultation plus Form C referral planning; version 1.8 records the owner-reported consultation configuration and a separate nine-route isolated-test adapter. No future commit SHA is invented.
@@ -221,7 +237,7 @@ Cross-checked against F-001–F-009 in the local CSV, current source and eligibl
| F-003 | `/client-consultation` | EN | `src/components/FormShell.astro:12`, `layouts/PageLayout.astro:15,82` | Form A / `consultation`, isolated-test only |
| F-004 | `/es/client-consultation` | ES | Same, explicit locale | Form A / `consultation`, isolated-test only |
| F-005 | `/schedule-consultation` | EN | Same FormShell | Form A / `consultation`, isolated-test only |
| F-006 | `/referrals` | EN | Same FormShell; CTA `#form-title` | C |
| F-006 | `/referrals` | EN | External button `Refer a Client` | Existing hosted Jotform https://form.jotform.com/231638510219149; no Form C |
| F-007 | `/aba-therapy` | EN | `src/components/ServiceInquiryDrawer.astro`, `layouts/PageLayout.astro` | Form A / `consultation`, isolated-test-only explicit drawer |
| F-008 | `/autism-evaluations` | EN | Same drawer | Form A / `consultation`, isolated-test-only explicit drawer |
| F-009 | `/learner-social-club` | EN | Same drawer | Form A / `consultation`, isolated-test-only explicit drawer |
@@ -10,7 +10,7 @@ JF-08,Embedding,"Approved HTTPS origin, hosted/iframe URLs, dimensions/mobile, s
JF-09,Controlled synthetic acceptance,"Actual EN/ES synthetic submission, notifications, success/failure, redirects and privacy boundary",Later separately authorized isolated form/environment; never real patient data,"Owner-confirmed 2026-10-28 direct-Jotform EN/ES and sampled Astro-iframe synthetic submissions with native thank-you and notification/autoresponder delivery. Functional submission acceptance is complete for the reported samples; exact ABA landing route, all-seven-route runtime/privacy and production-release acceptance remain open; 2026-10-30 supersession: 16 placements are default-live by owner authorization, with remaining all-route runtime/accessibility acceptance separate",Owner-verified — Form A functional submission samples,2026-10-28,Separate batch authorization; no action authorized by this checklist
OA-01,Intake/operations,"Minimum fields, required flags/contact preference consistency, follow-up/callback expectations, Tour/fundraising scope",Intake/operations,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
OA-02,Clinical leadership,"Eligibility, service/evaluation availability, private-pay Social Club distinction, referral boundary and approved claims",Clinical leadership,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
OA-03,Jotform account owner,"JF-01–08 for Form A consultation and Form C, Form C blank-form creation, actual configuration/ID and preservation of all existing forms",Jotform account owner,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
OA-03,Jotform account owner,"JF-01–08 for Form A consultation; confirm the existing hosted /referrals Jotform operational configuration and preservation of all existing forms",Jotform account owner,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
OA-04,English/Spanish reviewer,"Labels, errors, instructions, confirmations and locale switching; no automatic translation approval",English/Spanish reviewer,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
OA-05,Privacy/legal/security,"Protected fields, generic email/headers/recipients, embeds/referrers, vendor flows, access/retention and tracking boundaries",Privacy/legal/security,"Owner-verified 2026-10-27 privacy approval for Form A PHI protection, generic no-PHI notification, no PDF attachment, no webhooks/integrations, recipient access and retention/deletion; embed/referrer runtime review and tracking boundaries remain open","Partially verified — Form A configuration, access and retention scope",2026-10-27,Separate batch authorization; no action authorized by this checklist
OA-06,Marketing,Deferred attribution categories and redesigned successful-submission semantics,Marketing,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
@@ -18,5 +18,5 @@ OA-07,Hosting owner,"Static/API separation, compile/deploy gates, CSP/referrer/l
OA-08,Release owner,"Phase 10 integration activation, go/no-go, artifact/vendor rollback and deployment",Release owner,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
FA-PRD-01,Form A mode and placement contract,Superseded 2026-10-30: live is default on 16 fixed placements; explicit off/mock isolate forms; indexing is independent; invalid modes fail clearly,Release + hosting owner,Owner authorizes public development forms; repository registry/artifact/browser tests; no hosting deployment performed,Owner-authorized architecture — runtime/site-release acceptance separate,2026-10-30,No additional Form A build approval flags; site cutover requires separate authorization
FA-CONS-01,Form A consultation context,"Verify fixed `consultation` presentation value, required service-interest question, five approved choices, conditional email/phone behavior, EN/ES translations and no additional collection; no diagnostic-evaluation explanatory note",Account + operations + clinical + privacy owners,Owner reports connected-app configuration; sanitized Builder configuration and approved synthetic test evidence still required; no ID/condition inferred by repository,Owner-reported configured — browser acceptance pending,2026-10-29,Default-live consultation placements owner-authorized 2026-10-30; Jotform runtime/language acceptance and site cutover remain separate
FC-01,Form C referral initiation,"Create only after authorization, then verify minimum professional fields, no patient data instruction, PHI controls, notification/retention and protected-workflow handoff",Account + operations + privacy owners,Sanitized Form C Builder configuration and approved synthetic test evidence,Pending/Unverified,,Separate creation and synthetic-test authorization required
FC-01,Legacy professional referral link,"Form C canceled; verify the existing hosted Jotform destination and its form fields privacy recipient notifications accessibility and operational suitability",Account + operations + privacy owners,"Owner decision 2026-11-01: /referrals links to https://form.jotform.com/231638510219149; no account mutation or real submission in repository change",Form C canceled — legacy hosted form acceptance pending,2026-11-01,No Form C creation or embed authorization; separately verify existing hosted form before site release
INV-01,Supplied roster provenance and remaining verification,Complete 27-ID roster received and uniqueness checked; verify uncertain generic titles and relevant operational classification with account owner without inferring usage or retrieving submissions,Jotform account owner,Owner-supplied read-only connector roster; contract v1.2 section 3 lists all 27 unique IDs; individual roster no longer outstanding; new-form configuration and usage verification pending,Pending/Unverified,,Read-only metadata review authorization; no account mutation
1 gate_id subject verification_required owner_role evidence_source status approval_date authorization_required
10 JF-09 Controlled synthetic acceptance Actual EN/ES synthetic submission, notifications, success/failure, redirects and privacy boundary Later separately authorized isolated form/environment; never real patient data Owner-confirmed 2026-10-28 direct-Jotform EN/ES and sampled Astro-iframe synthetic submissions with native thank-you and notification/autoresponder delivery. Functional submission acceptance is complete for the reported samples; exact ABA landing route, all-seven-route runtime/privacy and production-release acceptance remain open; 2026-10-30 supersession: 16 placements are default-live by owner authorization, with remaining all-route runtime/accessibility acceptance separate Owner-verified — Form A functional submission samples 2026-10-28 Separate batch authorization; no action authorized by this checklist
11 OA-01 Intake/operations Minimum fields, required flags/contact preference consistency, follow-up/callback expectations, Tour/fundraising scope Intake/operations Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
12 OA-02 Clinical leadership Eligibility, service/evaluation availability, private-pay Social Club distinction, referral boundary and approved claims Clinical leadership Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
13 OA-03 Jotform account owner JF-01–08 for Form A consultation and Form C, Form C blank-form creation, actual configuration/ID and preservation of all existing forms JF-01–08 for Form A consultation; confirm the existing hosted /referrals Jotform operational configuration and preservation of all existing forms Jotform account owner Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
14 OA-04 English/Spanish reviewer Labels, errors, instructions, confirmations and locale switching; no automatic translation approval English/Spanish reviewer Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
15 OA-05 Privacy/legal/security Protected fields, generic email/headers/recipients, embeds/referrers, vendor flows, access/retention and tracking boundaries Privacy/legal/security Owner-verified 2026-10-27 privacy approval for Form A PHI protection, generic no-PHI notification, no PDF attachment, no webhooks/integrations, recipient access and retention/deletion; embed/referrer runtime review and tracking boundaries remain open Partially verified — Form A configuration, access and retention scope 2026-10-27 Separate batch authorization; no action authorized by this checklist
16 OA-06 Marketing Deferred attribution categories and redesigned successful-submission semantics Marketing Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
18 OA-08 Release owner Phase 10 integration activation, go/no-go, artifact/vendor rollback and deployment Release owner Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
19 FA-PRD-01 Form A mode and placement contract Superseded 2026-10-30: live is default on 16 fixed placements; explicit off/mock isolate forms; indexing is independent; invalid modes fail clearly Release + hosting owner Owner authorizes public development forms; repository registry/artifact/browser tests; no hosting deployment performed Owner-authorized architecture — runtime/site-release acceptance separate 2026-10-30 No additional Form A build approval flags; site cutover requires separate authorization
20 FA-CONS-01 Form A consultation context Verify fixed `consultation` presentation value, required service-interest question, five approved choices, conditional email/phone behavior, EN/ES translations and no additional collection; no diagnostic-evaluation explanatory note Account + operations + clinical + privacy owners Owner reports connected-app configuration; sanitized Builder configuration and approved synthetic test evidence still required; no ID/condition inferred by repository Owner-reported configured — browser acceptance pending 2026-10-29 Default-live consultation placements owner-authorized 2026-10-30; Jotform runtime/language acceptance and site cutover remain separate
21 FC-01 Form C referral initiation Legacy professional referral link Create only after authorization, then verify minimum professional fields, no patient data instruction, PHI controls, notification/retention and protected-workflow handoff Form C canceled; verify the existing hosted Jotform destination and its form fields privacy recipient notifications accessibility and operational suitability Account + operations + privacy owners Sanitized Form C Builder configuration and approved synthetic test evidence Owner decision 2026-11-01: /referrals links to https://form.jotform.com/231638510219149; no account mutation or real submission in repository change Pending/Unverified Form C canceled — legacy hosted form acceptance pending 2026-11-01 Separate creation and synthetic-test authorization required No Form C creation or embed authorization; separately verify existing hosted form before site release
22 INV-01 Supplied roster provenance and remaining verification Complete 27-ID roster received and uniqueness checked; verify uncertain generic titles and relevant operational classification with account owner without inferring usage or retrieving submissions Jotform account owner Owner-supplied read-only connector roster; contract v1.2 section 3 lists all 27 unique IDs; individual roster no longer outstanding; new-form configuration and usage verification pending Pending/Unverified Read-only metadata review authorization; no account mutation
@@ -0,0 +1,60 @@
# Phase 9 — Preserve existing referrals link, retire Form C
Date: 2026-11-01
Status: Owner-approved page-routing decision; operational form acceptance and site cutover remain separate
## Decision and live reference
The current public [referrals page](https://www.azinstitute4autism.com/referrals)
has a button-like **Refer a Client** link to
`https://form.jotform.com/231638510219149`.
The owner explicitly requests that the Astro migration preserve that direct
hosted-form destination rather than implement a separate Form C.
This overrides previous Form C referral-initiation planning. Earlier two-form
contracts, form-creation batches and checklists remain historical evidence, not
live instructions to create or launch a form. Physical Form B remains retired.
Form A remains a separate single form at its 16 approved existing placements.
## Code and publication behavior
- `src/content/pages/en/referrals.mdx` retains its button-like CTA and source
content; the CTA now links to the exact existing HTTPS Jotform URL with no
tracking/prefill/query parameters.
- `src/layouts/PageLayout.astro` no longer adds `FormShell` on `/referrals`.
- The unused `referral` native-form mapping and type are removed. The existing
`FormShell` is retained for applicable family consultation pages only.
- No new iframe, Jotform script, webhook, provider integration, form ID, field,
server POST, API origin, measurement, redirect or protected-data collection
is added to the Astro site.
- `/referrals` remains its own published route with existing title, canonical,
body and SEO policy. The Form A allowlist remains unchanged and explicitly
excludes `/referrals`.
## Privacy and operational acceptance
A live-page link is evidence of the intended **URL**, not proof of the linked
form's configuration or compliance. The hosted form presently identifies
itself as a program-consultation form and visibly asks for child and family
details, so the form owner must confirm that this remains an appropriate
professional-referral journey.
Before cutover, the owner should verify the existing Jotform account's access,
PHI handling, privacy settings, recipients and notifications, retention,
attachments, integrations, confirmation behavior, accessibility and
availability. Do not infer a new form's safe defaults from Form A's reviewed
configuration. No account edits or submissions are authorized or performed
by this repository change.
## Regression acceptance
For both default staging and indexing builds, `/referrals` must include one
button-styled `Refer a Client` anchor pointing to the exact external destination,
with no local lead form, `#form-title` fallback or Form A iframe. All other
contact/consultation/landing routes retain their existing behavior.
Updated unit/publication assertions cover the external link, removal of the
inactive referral form, route mapping, and preservation of other form surfaces.
These are source-level assertions until run by the repository owner or CI.
No production deployment, DNS change, Jotform mutation or site cutover is
included in this PR.
@@ -1,4 +1,20 @@
# Phase 9 — Two-form integration specification
# Phase 9 — Two-form integration specification (historical title)
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
## Current decision — 2026-10-30: default-live Form A
@@ -14,7 +30,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
origin, context and language. Forms B/C are not activated; physical B is retired.
origin, context and language. Physical Form B is retired; the Form C proposal was canceled on 2026-11-01.
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for
@@ -34,10 +50,10 @@ current environment setup instructions.
**Recorded:** 2026-10-28 UTC; revised 2026-10-30 UTC
**Scope:** Default-live Form A integration and future Form C referral initiation
**Scope:** Default-live Form A integration; former Form C plan superseded by existing hosted referral link
**Status:** 16 default-live placements owner-authorized; Jotform language/runtime acceptance and site cutover remain separate
## Decision revision
## Decision revision (historical, superseded for Form C)
The owner has replaced the former three-physical-form plan with two physical Jotform forms:
@@ -78,7 +94,10 @@ Do not add a conditional explanatory note when **Autism Diagnostic Evaluation**
Do not add child name, date of birth, age, diagnosis, medical history, records, insurance member ID, payment, location or booking questions to the initial consultation context. Do not make any additional consultation field required without a separately documented operations, clinical and privacy decision. Whether an existing optional free-text message remains visible requires the existing Form A privacy treatment; it is not an additional required consultation field under this specification.
## Form C — Professional Referral Initiation
## Historical Form C proposal (canceled 2026-11-01)
The following field plan is retained only as historical evidence. It is not a task,
cutover dependency, or direction to create another Jotform.
Form C remains a separate future physical Jotform created from a blank form. Its initial, minimum-necessary specification is:
@@ -107,7 +126,7 @@ The following nine placements join the original seven in one live registry. Both
| `/es/aba-therapy` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
| `/es/autism-evaluations` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
| `/es/learner-social-club` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
| `/referrals` | EN | `FormShell.astro`, `logicalForm=referral`; inactive | Form C / future referral initiation | Create and verify Form C; no patient data in initial form |
| `/referrals` | EN | One `Refer a Client` external button; no local form | Existing hosted Jotform https://form.jotform.com/231638510219149, distinct from Form A | Confirm existing form's recipient/privacy/accessibility controls; no Form C |
English and Spanish service-page CTAs open their authorized live drawer with JavaScript, retaining their consultation-page navigation destination otherwise. They are not additional iframe placements. The same is true of homepage, services, FAQ and library consultation CTAs that resolve to those pages.
@@ -121,7 +140,7 @@ The current Tour CTA in `src/content/pages/en/tour.mdx` points to `/client-consu
4. Obtain intake/operations, clinical and privacy approval for the field set and approved service options. Obtain fluent English/Spanish review for all added presentation and error text.
5. Run separately authorized synthetic Jotform and Astro-iframe acceptance tests with no real patient data. Cover both locales, all service choices, validation/error states, no-JavaScript and keyboard behavior, confirmation, notifications, responsive sizes and direct hosted fallback.
6. Record acceptance separately from the owner-authorized default-live 16-route registry. Site deployment/cutover still requires release authorization; no further Form A build flags are required.
7. For Form C, separately authorize creation from a blank form, then verify its minimum fields, PHI protections, recipients, retention, hosted/iframe behavior and protected-workflow handoff before mapping `/referrals`.
7. For `/referrals`, confirm the existing hosted Jotform destination, permissions, response handling, patient-data privacy, staff recipients and operational suitability. Do not create Form C.
## Retained gates
+1 -1
View File
@@ -7,7 +7,7 @@ import { formModeForRuntime, resolveFormAIframe, resolveFormMode, resolveMockFor
const { lang = 'en', showBackground = false, route = '' } = Astro.props;
const text = formMessages(lang);
const title = Astro.props.title ?? text.heading;
const logicalForm: LogicalForm = Astro.props.logicalForm ?? (Astro.props.title === 'Physician & Insurer Referrals' ? 'referral' : 'service');
const logicalForm: LogicalForm = Astro.props.logicalForm ?? 'service';
const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE);
const mode = formModeForRuntime(config);
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
+57
View File
@@ -0,0 +1,57 @@
---
import { formMessages } from '../utils/form-messages';
const { lang = 'en' } = Astro.props;
const text = formMessages(lang);
const directions = 'https://www.google.com/maps/dir/?api=1&destination=8901%20E%20Raintree%20Dr%20Ste%20160%2C%20Scottsdale%2C%20AZ%2085260';
---
<section class="openstreetmap-section" aria-label={text.map}>
<div id="aia-openstreetmap" class="openstreetmap" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker} data-directions-label={text.directions}></div>
<p data-openstreetmap-fallback>
Arizona Institute for Autism — 8901 E Raintree Dr, Ste 160, Scottsdale, AZ 85260.
<a href={directions} target="_blank" rel="noopener noreferrer">{text.directions}</a>
</p>
</section>
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script is:inline define:vars={{ directions }}>
(() => {
const element = document.getElementById('aia-openstreetmap');
if (!element || !window.L || element.dataset.initialized) return;
const map = window.L.map(element, { zoomControl: false }).setView([33.63, -111.88756], 12);
element.dataset.initialized = 'true';
window.L.control.zoom({ zoomInTitle: element.dataset.zoomIn, zoomOutTitle: element.dataset.zoomOut }).addTo(map);
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
tiles.on('tileload', ({ tile }) => {
tile.alt = element.dataset.tileAlt;
});
tiles.addTo(map);
window.L.marker([33.617746, -111.88756], {
alt: element.dataset.markerLabel,
title: element.dataset.markerLabel
}).addTo(map).bindPopup(`
<span class="openstreetmap__popup">
<b>Arizona Institute for Autism</b><br>
<img alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94"><br>
<a href="${directions}" target="_blank" rel="noopener noreferrer">${element.dataset.directionsLabel}</a>
</span>
`).openPopup();
// Keep address/directions available without JavaScript or if Leaflet is blocked.
document.querySelector('[data-openstreetmap-fallback]')?.setAttribute('hidden', '');
})();
</script>
<style>
.openstreetmap-section { background: white; margin: 0; padding: 0; }
.openstreetmap { height: 360px; width: 100%; }
.openstreetmap :global(.openstreetmap__popup) { display: block; text-align: center; }
.openstreetmap :global(.leaflet-popup-content) { margin: 10px 20px 8px 18px; }
.openstreetmap :global(.openstreetmap__popup img) { display: inline-block; margin-bottom: .25rem; }
.openstreetmap :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 2px; }
[data-openstreetmap-fallback] { margin: 0; padding: 1rem; text-align: center; }
[hidden] { display: none; }
</style>
+2 -52
View File
@@ -2,7 +2,7 @@
import ContactForm from '../ContactForm.astro';
import FormAPreview from '../FormAPreview.astro';
import JotformIframe from '../JotformIframe.astro';
import { formMessages } from '../../utils/form-messages';
import OpenStreetMap from '../OpenStreetMap.astro';
import { formAPreviewEnabled, formModeForRuntime, resolveFormAIframe, resolveFormMode } from '../../utils/form-integration';
interface Props {
@@ -11,7 +11,6 @@ interface Props {
}
const { title, lang } = Astro.props;
const text = formMessages(lang);
const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE);
const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, formModeForRuntime(formConfig));
const route = lang === 'es' ? '/es/contact' : '/contact';
@@ -35,40 +34,7 @@ const formAIframe = resolveFormAIframe({
</div>
</section>
<section class="contact-map-section" aria-label={text.map}>
<div id="aia-contact-map" class="contact-map" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker}></div>
</section>
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script is:inline>
(() => {
const mapElement = document.getElementById('aia-contact-map');
if (!mapElement || !window.L) return;
const map = window.L.map(mapElement, { zoomControl: false }).setView([33.63, -111.88756], 12);
window.L.control.zoom({ zoomInTitle: mapElement.dataset.zoomIn, zoomOutTitle: mapElement.dataset.zoomOut }).addTo(map);
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
});
tiles.on('tileload', (event) => {
if (event.tile && !event.tile.alt) {
event.tile.alt = mapElement.dataset.tileAlt;
}
});
tiles.addTo(map);
window.L.marker([33.617746, -111.88756], { alt: mapElement.dataset.markerLabel, title: mapElement.dataset.markerLabel }).addTo(map).bindPopup(`
<span style="display:block;text-align:center">
<b>Arizona Institute for Autism</b><br>
<img class="contact-map__logo" alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94">
</span>
`).openPopup();
})();
</script>
<OpenStreetMap lang={lang} />
<style>
.contact-page {
@@ -106,22 +72,6 @@ const formAIframe = resolveFormAIframe({
margin-bottom: 40px;
}
.contact-map-section {
background: white;
margin: 0;
padding: 0;
}
.contact-map {
height: 360px;
width: 100%;
}
.contact-map :global(.contact-map__logo) {
display: block;
margin-inline: auto;
}
@media (max-width: 992px) {
.contact-page {
padding-block: 50px;
+20 -12
View File
@@ -3,9 +3,11 @@ 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,
localApproved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED,
productionApproved: import.meta.env.AIA_PORTAL_PRODUCTION_APPROVED,
indexing: import.meta.env.PUBLIC_ALLOW_INDEXING,
file: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
localFile: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
productionFile: import.meta.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE,
});
const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null';
---
@@ -31,9 +33,10 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
.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; }
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content:focus { outline: none; }
[hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; }
.employee-portal__content :global(a) { color: var(--color-primary); }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content { line-height: 1.65; }
.employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) {
@@ -63,14 +66,19 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
min-width: 0;
}
.employee-portal__content :global(ul.portal-template-grid > li p) { margin: 0; }
.employee-portal__content :global(ul.portal-template-grid a.portal-resource-button) {
align-items: center;
box-sizing: border-box;
display: flex;
height: 100%;
min-height: 2.75rem;
overflow-wrap: anywhere;
width: 100%;
/* Staff resource-button lists do not display bullets or numbers. */
.employee-portal__content :global(.portal-resource-list) {
list-style: none;
padding-inline-start: 0;
}
.employee-portal__content :global(.portal-resource-list > li) {
list-style: none;
}
.employee-portal__content :global(.portal-resource-list > li::marker) {
content: none;
}
.employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; }
+1 -1
View File
@@ -26,4 +26,4 @@ At the Arizona Institute for Autism, we are here to support your clients reach n
To provide the highest level of optimal care, we know that it is essential to provide a working partnership with the client’s diagnostic provider, physician, and their insurance company. To facilitate this collaboration, we have built a system of reporting metrics on the child’s treatment plan with supporting progress charts we share with our partners. This information keeps everyone involved up to date on the client’s progress and future needs. If you are an insurance company or diagnostic provider interested in working with us, contact us to learn more about our referral process.
<Button href="#form-title" center>Refer a Client</Button>
<Button href="https://form.jotform.com/231638510219149" center>Refer a Client</Button>
+4 -1
View File
@@ -8,6 +8,7 @@ translationKey: "tour"
draft: false
---
import ImageCarousel from '../../../components/ImageCarousel.astro';
import Button from '../../../components/Button.astro';
import tourGallery from '../../../data/tour-gallery.json';
## Tour Arizona Institute for Autism
@@ -50,4 +51,6 @@ Arizona's Leading Modern Learning Experience for Children Diagnosed with Autism
- On-Site Clinical Case Supervisor
[Schedule a Tour Today](/client-consultation)
<div style={{ textAlign: 'center', marginBlock: '2.5rem' }}>
<Button href="/client-consultation">Schedule a Tour Today</Button>
</div>
+6 -3
View File
@@ -7,13 +7,14 @@ import AboutPage from '../components/pages/AboutPage.astro';
import ServicesPage from '../components/pages/ServicesPage.astro';
import CareersPage from '../components/pages/CareersPage.astro';
import ContactPage from '../components/pages/ContactPage.astro';
import OpenStreetMap from '../components/OpenStreetMap.astro';
import FaqPage from '../components/pages/FaqPage.astro';
import InsurancePage from '../components/pages/InsurancePage.astro';
import PageHero from '../components/PageHero.astro';
import ServiceInquiryDrawer from '../components/ServiceInquiryDrawer.astro';
import { pageHeroImages } from '../data/page-visuals';
const { entry } = Astro.props;
const showForm = ['client-consultation', 'schedule-consultation', 'referrals'].includes(entry.data.slug);
const showForm = ['client-consultation', 'schedule-consultation'].includes(entry.data.slug);
const serviceTitles: Record<string, Record<string, string>> = {
en: {
'aba-therapy': 'Behavioral',
@@ -55,13 +56,14 @@ const pageTitles: Record<string, Record<string, string>> = {
const bannerTitle = serviceTitles[entry.data.lang]?.[entry.data.slug] || pageTitles[entry.data.lang]?.[entry.data.slug] || entry.data.title.split('|')[0].trim();
const isService = Boolean(serviceTitles[entry.data.lang]?.[entry.data.slug]);
const route = entry.data.lang === 'en' ? `/${entry.data.slug}` : `/${entry.data.lang}/${entry.data.slug}`;
const isTour = entry.data.lang === 'en' && entry.data.slug === 'tour';
const heroImage = pageHeroImages[entry.data.slug];
if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team) {
throw new Error('The English Team page requires structured team frontmatter.');
}
---
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage || heroImage} lang={entry.data.lang}>
{entry.data.slug === 'contact' && <Fragment slot="head"><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /></Fragment>}
{(entry.data.slug === 'contact' || isTour) && <Fragment slot="head"><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /></Fragment>}
<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!} />
@@ -82,6 +84,7 @@ if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team)
: entry.data.slug === 'contact'
? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage>
: <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>}
{isTour && <OpenStreetMap lang={entry.data.lang} />}
{isService && (entry.data.lang === 'en' || entry.data.lang === 'es') && <ServiceInquiryDrawer service={entry.data.slug} lang={entry.data.lang} route={route} />}
{showForm && <FormShell lang={entry.data.lang} route={route} title={bannerTitle} logicalForm={entry.data.slug === 'referrals' ? 'referral' : 'service'} showBackground={entry.data.slug === 'client-consultation'} />}
{showForm && <FormShell lang={entry.data.lang} route={route} title={bannerTitle} logicalForm="service" showBackground={entry.data.slug === 'client-consultation'} />}
</BaseLayout>
+13
View File
@@ -76,6 +76,19 @@ function enhanceStaffPortalPresentation(fragment: DocumentFragment): void {
}
if (!templatesList) templatesList = templatesHeading.closest('section, article')?.querySelector('ul') ?? null;
templatesList?.classList.add('portal-template-grid');
// Remove markers only from staff lists containing resource buttons.
for (const list of fragment.querySelectorAll('ul, ol')) {
const items = [...list.children].filter(
(child) => child.tagName === 'LI'
);
if (
items.length > 0 &&
items.every((item) => item.querySelector('a.portal-resource-button'))
) {
list.classList.add('portal-resource-list');
}
}
}
function initialize() {
const root = document.querySelector<HTMLElement>('[data-employee-portal]');
+1 -2
View File
@@ -1,5 +1,5 @@
export type FormMode = 'live' | 'off' | 'mock';
export type LogicalForm = 'general' | 'service' | 'referral';
export type LogicalForm = 'general' | 'service';
export type MockFormResult = 'success' | 'failed' | 'unavailable';
export interface FormModeConfig {
@@ -170,7 +170,6 @@ export const formRouteMapping: ReadonlyArray<{
{ route: '/client-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/es/client-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/schedule-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/referrals', logicalForm: 'referral', kind: 'native-instance' },
{ route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' },
{ route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' },
{ route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' },
+2 -2
View File
@@ -10,7 +10,7 @@ const messages = {
mock: 'Demo only: this local fixture never sends information.', mockSuccess: 'Demo only: the local fixture completed without sending anything.', mockFailed: 'Demo only: the local fixture simulated a failed submission.', mockUnavailable: 'Demo only: the local fixture simulated an unavailable service.',
map: 'Map showing Arizona Institute for Autism in Scottsdale',
tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ',
zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism',
zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism', directions: 'Get Directions',
formAPreview: {
internalPreview: 'Internal meeting preview', heading: 'General enquiries preview',
name: 'Your name', phone: 'Phone number', email: 'Email address',
@@ -35,7 +35,7 @@ const messages = {
mock: 'Solo demostración: esta prueba local nunca envía información.', mockSuccess: 'Solo demostración: la prueba local terminó sin enviar información.', mockFailed: 'Solo demostración: la prueba local simuló un envío fallido.', mockUnavailable: 'Solo demostración: la prueba local simuló un servicio no disponible.',
map: 'Mapa de la ubicación de Arizona Institute for Autism en Scottsdale',
tile: 'Mapa de OpenStreetMap de los alrededores de Arizona Institute for Autism en Scottsdale, Arizona',
zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism',
zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism', directions: 'Cómo llegar',
formAPreview: {
internalPreview: 'Vista previa para reunión interna', heading: 'Vista previa de consultas generales',
name: 'Su nombre', phone: 'Número de teléfono', email: 'Correo electrónico',
+11 -1
View File
@@ -6,7 +6,17 @@ export function isFormACompletion(event: { origin: string; source: unknown; data
// never from the message, current child location, query string or referrer.
let expected: URL;
try { expected = new URL(approvedUrl); } catch { return false; }
if (expected.protocol !== 'https:' || event.origin !== expected.origin) return false;
if (expected.protocol !== 'https:') return false;
// HIPAA-enabled Jotform submissions can send completion messages
// from a different origin than the original embedded form.
const approvedOrigins = new Set([expected.origin]);
if (expected.origin === 'https://form.jotform.com') {
approvedOrigins.add('https://hipaa-submit.jotform.com');
}
if (!approvedOrigins.has(event.origin)) return false;
let data = event.data;
if (typeof data === 'string') {
if (data.length > 4096) return false;
+14 -4
View File
@@ -1,10 +1,20 @@
// 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, file }) {
if (approved !== 'true' || indexing !== 'false' || !file) return undefined;
/** Build-only reader. Local preview and approved public publication are distinct. */
export function readPortalInput({ localApproved, productionApproved, indexing, localFile, productionFile }) {
const local = localApproved === 'true';
const production = productionApproved === 'true';
if (local && production) throw new Error('Choose either local-preview or production portal ciphertext, never both');
const file = local ? localFile : production ? productionFile : undefined;
const permitted = (local && indexing === 'false') || (production && indexing === 'true');
if (!permitted) {
if (local || production) throw new Error('Portal ciphertext approval does not match this publication mode');
return undefined;
}
if (!file) throw new Error('Approved portal ciphertext file is required for this build');
try {
if (fs.statSync(file).size > 12_000_000) return undefined;
if (fs.lstatSync(file).isSymbolicLink() || fs.statSync(file).size > 12_000_000) throw new Error('Portal ciphertext must be a regular bounded file');
return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8')));
} catch { return undefined; }
} catch { throw new Error('Approved portal ciphertext is missing or malformed'); }
}
+101
View File
@@ -0,0 +1,101 @@
/** Prepare and optionally transfer already-approved employee resources. */
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { rsyncArguments, sshTarget } from './deploy.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..');
const root = '/srv/www/azinstitute4autism.com';
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const fullSha = /^[0-9a-f]{40}$/;
function fail(message) { throw new Error(message); }
function run(command, args, { quiet = false } = {}) {
const result = spawnSync(command, args, { cwd: toolRoot, encoding: 'utf8' });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
if (!quiet && result.stdout) process.stdout.write(result.stdout);
return result.stdout.trim();
}
function safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
function roleAndName(record) {
if (!['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) fail('Every installed resource must have an approved internal classification');
const role = record.classification === 'STAFF-INTERNAL' ? 'staff' : 'management';
const prefix = `/${role}-resources/`;
if (!record.destinationImplemented || !record.destinationVerified || !record.destinationAccessControlled || !record.rewriteApproved || typeof record.deliveryPath !== 'string' || !record.deliveryPath.startsWith(prefix)) fail('Resource destination is not deployment-approved');
const name = record.deliveryPath.slice(prefix.length);
if (!safeName(name) || record.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
return { role, name };
}
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
if (!fullSha.test(sourceCommit)) fail('Full source Git SHA required');
const manifestPath = path.join(privateRoot, 'manifest.json');
const manifestStat = await fs.lstat(manifestPath).catch(() => null);
if (!manifestStat?.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > 4_000_000) fail('Private employee resource manifest is unavailable or unsafe');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
const seen = new Set(), files = [];
for (const record of manifest.resources) {
if (!['STAFF-INTERNAL','MANAGEMENT-INTERNAL'].includes(record.classification)) continue;
const { role, name } = roleAndName(record);
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
seen.add(`${role}/${name}`);
const source = path.join(privateRoot, 'hubfs', record.localFile);
const stat = await fs.lstat(source).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
const bytes = await fs.readFile(source);
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
const destination = path.join(staging, role);
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
}
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
}
export function serializeResourcePlan(plan) {
if (plan?.schema !== 1 || !fullSha.test(plan.sourceCommit ?? '') || !Array.isArray(plan.files)) fail('Invalid resource installation plan');
const records = plan.files.map((entry) => {
if (!['staff', 'management'].includes(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0) fail('Invalid resource installation plan entry');
return ['file', entry.role, entry.name, entry.stageName, entry.sha256, String(entry.size)].join('\t');
});
return `AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=${plan.sourceCommit}\n${records.join('\n')}\n`;
}
function parse(argv) {
const valid = new Set(['--dry-run','--confirm']);
if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm');
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') };
}
async function main() {
const options = parse(process.argv.slice(2));
const sha = run('git', ['-C', repositoryRoot, 'rev-parse', 'HEAD'], { quiet: true });
if (!fullSha.test(sha) || run('git', ['-C', repositoryRoot, 'status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Resource installation requires a clean committed worktree');
const privateRoot = process.env.AIA_PORTAL_PRIVATE_ROOT || path.join(toolRoot, 'private', 'employee-portal');
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-'));
try {
const staging = path.join(temporary, `portal-resources-${sha}`);
await Promise.all(['staff', 'management'].map((role) => fs.mkdir(path.join(staging, role), { recursive: true, mode: 0o700 })));
const plan = await prepareResourcePlan(privateRoot, staging, sha);
const planFile = path.join(temporary, `aia-portal-resources-${sha}.tsv`);
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; }
if (!options.confirm) fail('Refusing employee-file transfer without --confirm');
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) {
const args = rsyncArguments(path.join(staging, role), `${host}:${remoteStage}/${role}`, { remove: false });
args.splice(-2, 0, '-e', transport);
run('rsync', args);
}
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]);
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; });
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { prepareResourcePlan, serializeResourcePlan } from './deploy-portal-resources.mjs';
const digest = (value) => createHash('sha256').update(value).digest('hex');
const sha = 'a'.repeat(40);
const scriptSource = path.join(path.dirname(fileURLToPath(import.meta.url)), 'portal-resource-install.sh');
async function fixture(prefix = 'aia-resource-plan-') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const privateRoot = path.join(root, 'private');
const hubfs = path.join(privateRoot, 'hubfs');
const stage = path.join(root, 'staging', `portal-resources-${sha}`);
await fs.mkdir(hubfs, { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'staff'), { recursive: true });
await fs.mkdir(path.join(root, 'secure-resources', 'management'), { recursive: true });
return { root, privateRoot, hubfs, stage };
}
async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('synthetic non-phi document')) {
const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
}] }));
return { localFile, bytes };
}
test('resource plan copies only hash-verified approved protected resources', async () => {
const { root, privateRoot, hubfs, stage } = await fixture();
try {
const { localFile, bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
assert.equal(plan.files.length, 1);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] }));
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /destination/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('portable shell installer executes the reviewed staged-plan interface with synthetic files', async () => {
const { root, privateRoot, hubfs, stage } = await fixture('aia-portal-install-');
try {
const { bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
await fs.mkdir(path.join(stage, 'management'), { recursive: true });
const planFile = path.join(root, 'approved-plan.tsv');
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
const fakeBin = path.join(root, 'fake-bin');
const log = path.join(root, 'ownership.log');
await fs.mkdir(fakeBin);
await fs.writeFile(path.join(fakeBin, 'id'), '#!/bin/sh\n[ "$1" = -u ] && { echo 0; exit 0; }\nexec /usr/bin/id "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'getent'), '#!/bin/sh\n[ "$1" = group ] && [ "$2" = nginx ] && { echo "nginx:x:999:"; exit 0; }\nexec /run/current-system/sw/bin/getent "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'install'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\nwhile [ "$#" -gt 0 ]; do\n case "$1" in -o|-g|-m) shift 2 ;; *) break ;; esac\ndone\nexec /usr/bin/install -m 0640 "$@"\n`, { mode: 0o755 });
const script = path.join(root, 'portal-resource-install.sh');
const source = await fs.readFile(scriptSource, 'utf8');
assert.ok(!source.includes('node '));
await fs.writeFile(script, source.replace('ROOT=/srv/www/azinstitute4autism.com', `ROOT=${root}`), { mode: 0o755 });
const result = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.equal(result.status, 0, result.stderr);
assert.equal((await fs.readFile(path.join(root, 'secure-resources/staff/guide.pdf'))).toString(), bytes.toString());
assert.match(await fs.readFile(log, 'utf8'), /-o root -g nginx -m 0640/);
const nonRoot = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH } });
assert.notEqual(nonRoot.status, 0);
assert.match(nonRoot.stderr, /requires root/);
await fs.writeFile(path.join(stage, 'staff', 'unexpected.pdf'), 'unexpected');
const rejected = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /Unexpected staged resource/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
+24
View File
@@ -0,0 +1,24 @@
/** Reactivate one retained, reviewed Contabo release. No build or NixOS action. */
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { sshTarget } from './deploy.mjs';
const sha = /^[0-9a-f]{40}$/;
function fail(message) { throw new Error(message); }
function run(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
if (result.stdout) process.stdout.write(result.stdout);
}
function main() {
const [release, confirmation] = process.argv.slice(2);
if (!sha.test(release ?? '') || confirmation !== '--confirm' || process.argv.length !== 4) fail('Usage: npm run deploy:rollback -- FULL_APPROVED_SHA --confirm');
const [ssh, ...options] = sshTarget(process.env.AIA_DEPLOY_HOST, process.env.AIA_DEPLOY_SSH_IDENTITY);
// The release manager verifies the stored release and manifest before its
// atomic symlink update. The SHA is strictly validated before interpolation.
run(ssh, [...options, process.env.AIA_DEPLOY_HOST, `aia-website-release rollback /root/aia-site-${release}.manifest.json && readlink /srv/www/azinstitute4autism.com/www/dist`]);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { main(); } catch (error) { console.error(`Rollback stopped: ${error.message}`); process.exitCode = 1; }
}
+205
View File
@@ -0,0 +1,205 @@
/**
* AIA static-site publisher. This tool prepares a reviewed release locally and
* uses only the Contabo release-manager interface for remote verification and
* atomic promotion. It never writes the active www/dist symlink directly.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { encryptPortalStream, portalAlgorithm, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..');
const projectRoot = '/srv/www/azinstitute4autism.com';
const fullSha = /^[0-9a-f]{40}$/;
const publicExtensions = new Set(['.html','.css','.js','.json','.xml','.txt','.svg','.png','.jpg','.jpeg','.webp','.gif','.ico','.avif','.woff','.woff2','.ttf','.otf','.pdf','.mp4','.webm','.mp3']);
const sourceRejectedParts = new Set(['.git','.github','node_modules','private','secrets','dist','.astro']);
const sourceRejectedNames = new Set(['.netrc','.npmrc','.pypirc','.yarnrc','.yarnrc.yml','auth.json','credentials','id_ed25519','id_rsa']);
const stableCompare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
function fail(message) { throw new Error(message); }
function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
function run(command, args, { cwd = toolRoot, env = process.env, quiet = false } = {}) {
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' });
if (result.error) fail(`${command} is unavailable: ${result.error.message}`);
if (!quiet && result.stdout) process.stdout.write(result.stdout);
if (result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
return result.stdout.trim();
}
function commandExists(command) { return spawnSync('which', [command], { stdio: 'ignore' }).status === 0; }
function safeRelative(name) {
const normalized = name.replaceAll(path.sep, '/');
if (!normalized || normalized.startsWith('/') || normalized.split('/').some((part) => !part || part === '.' || part === '..' || /[\x00-\x1f\\";{}]/.test(part))) fail(`Unsafe inventory path: ${name}`);
return normalized;
}
function publicPath(name) {
const safe = safeRelative(name);
if ((safe.split('/').some((part) => part.startsWith('.')) && safe !== 'assets/media/.gitkeep') || (!publicExtensions.has(path.extname(safe).toLowerCase()) && safe !== 'assets/media/.gitkeep') || safe.split('/').some((part) => ['secrets','private','node_modules','.git'].includes(part.toLowerCase()))) fail(`Unexpected public artifact path: ${safe}`);
return safe;
}
function sourcePath(name) {
const safe = safeRelative(name);
const parts = safe.split('/');
if (safe === 'merge-plan.md' || ['assets','releases','secure-resources','staging'].includes(parts[0]) || parts.some((part) => sourceRejectedParts.has(part.toLowerCase())) || (parts[0] === 'www' && parts[1] === 'dist') || safe === '.aia-source-state.json' || safe === '.aia-source-pending.json' || path.basename(safe) === '.env' || (path.basename(safe).startsWith('.env.') && path.basename(safe) !== '.env.example') || sourceRejectedNames.has(path.basename(safe).toLowerCase()) || ['.key','.pem','.p12','.pfx'].includes(path.extname(safe).toLowerCase())) fail(`Tracked path is not deployable source: ${safe}`);
return safe;
}
function treeSha256(files) {
const digest = createHash('sha256');
for (const [name, meta] of Object.entries(files).sort(([a], [b]) => stableCompare(a, b))) digest.update(`${name}\0${meta.sha256}\n`);
return digest.digest('hex');
}
export async function releaseManifest(dist, sourceCommit) {
if (!fullSha.test(sourceCommit)) fail('A full source Git SHA is required');
const files = {}, directories = [];
async function walk(relative = '') {
const current = path.join(dist, relative);
for (const entry of (await fs.readdir(current, { withFileTypes: true })).sort((a, b) => stableCompare(a.name, b.name))) {
const child = relative ? `${relative}/${entry.name}` : entry.name;
const stat = await fs.lstat(path.join(dist, child));
if (stat.isSymbolicLink() || !(stat.isDirectory() || stat.isFile()) || (stat.isFile() && stat.nlink !== 1)) fail(`Unsafe artifact entry: ${child}`);
if (stat.isDirectory()) {
const safe = safeRelative(child);
if (safe.split('/').some((part) => ['private','hubfs','staff-resources','management-resources','secrets'].includes(part.toLowerCase()))) fail(`Unexpected private artifact directory: ${safe}`);
directories.push(safe); await walk(child); continue;
}
publicPath(child);
const bytes = await fs.readFile(path.join(dist, child));
if (bytes.includes(Buffer.from('PRIVATE KEY-----'))) fail(`Private-key material in artifact: ${child}`);
files[child] = { sha256: sha256(bytes), size: bytes.length };
}
}
await walk();
if (!files['index.html']) fail('Artifact is missing index.html');
const sortedFiles = Object.fromEntries(Object.entries(files).sort(([a], [b]) => stableCompare(a, b)));
return { schema: 1, sourceCommit, treeSha256: treeSha256(sortedFiles), files: sortedFiles, directories: directories.sort(stableCompare), immutableAssets: Object.keys(sortedFiles).filter((name) => /^_astro\/[^/]+\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(name)).sort(stableCompare) };
}
export function rsyncArguments(source, destination, { remove = false } = {}) {
return ['-rlt', ...(remove ? ['--delete'] : []), '--links', '--chown=root:root', '--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', source.endsWith('/') ? source : `${source}/`, destination];
}
export function sshTarget(host, identity) {
if (!/^[A-Za-z0-9_.:@\-\[\]]+$/.test(host ?? '')) fail('AIA_DEPLOY_HOST is invalid');
if (!identity || !path.isAbsolute(identity)) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
return ['ssh', '-i', identity, '-o', 'IdentitiesOnly=yes'];
}
function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); }
function assertPreflight({ requirePrimary = true } = {}) {
if (git(['rev-parse', '--is-inside-work-tree'], { quiet: true }) !== 'true') fail('Run deploy from the AIA repository worktree');
if (requirePrimary && git(['branch', '--show-current'], { quiet: true }) !== 'faithful-astro-migration') fail('Deploy only from faithful-astro-migration after its reviewed commit is checked out');
const sha = git(['rev-parse', 'HEAD'], { quiet: true });
if (!fullSha.test(sha)) fail('Unable to determine full Git commit SHA');
if (git(['status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Deploy requires a clean tracked and untracked worktree');
const gitDir = git(['rev-parse', '--git-dir'], { quiet: true });
for (const state of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']) {
if (spawnSync('test', ['-e', path.join(repositoryRoot, gitDir, state)]).status === 0) fail(`Deploy refused during Git ${state}`);
}
for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`);
return sha;
}
async function payloadFrom(file) {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) fail('Portal ciphertext must be a bounded regular private file');
return validatePortalPayload(JSON.parse(await fs.readFile(file, 'utf8')));
}
async function syntheticPayload(directory) {
const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'),
await encryptPortalStream('synthetic-management', '<p>Synthetic portal fixture.</p>', 'synthetic-management-code'),
] };
const file = path.join(directory, 'synthetic-portal-ciphertext.json');
await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 });
return { file, payload, synthetic: true };
}
async function createSourceSnapshot(directory, sourceCommit) {
const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean);
const included = [], excluded = [];
for (const name of tracked) {
try { sourcePath(name); included.push(name); }
catch (error) {
if (name === 'merge-plan.md' || name === '.github' || name.startsWith('.github/')) excluded.push(name);
else fail(error.message);
}
}
if (!included.includes('www/package.json') || !included.some((name) => name.startsWith('www/src/'))) fail('Sanitized source snapshot is incomplete');
const archive = path.join(directory, 'source.tar');
const result = spawnSync('git', ['-C', repositoryRoot, 'archive', '--format=tar', sourceCommit, '--', ...included], { maxBuffer: 512 * 1024 * 1024 });
if (result.status !== 0) fail('git archive failed while producing the source snapshot');
await fs.writeFile(archive, result.stdout, { mode: 0o600 });
const snapshot = path.join(directory, `source-${sourceCommit}`);
await fs.mkdir(snapshot, { mode: 0o700 });
run('tar', ['-xf', archive, '-C', snapshot]);
await fs.rm(archive);
return { snapshot, included: included.length, excluded };
}
function validationEnvironment() {
const env = { ...process.env };
// Regression fixtures build their own publication modes. Do not let the
// production-only portal payload gate leak into those isolated builds.
for (const name of ['AIA_PORTAL_LOCAL_PREVIEW_APPROVED', 'AIA_PORTAL_PRODUCTION_APPROVED', 'AIA_PORTAL_CIPHERTEXT_FILE', 'AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE']) delete env[name];
return env;
}
async function executeBuild(buildEnv) {
const testEnv = validationEnvironment();
run('npm', ['run', 'check'], { env: testEnv });
run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs'], { env: testEnv });
run('npm', ['run', 'test:publication'], { env: testEnv });
run('npm', ['run', 'audit:links'], { env: testEnv });
run('npm', ['run', 'audit:images'], { env: testEnv });
run('npm', ['run', 'audit:blog'], { env: buildEnv });
run('npm', ['run', 'build'], { env: buildEnv });
}
function parseArgs(argv) {
const allowed = new Set(['--dry-run', '--confirm']);
if (argv.some((arg) => !allowed.has(arg))) fail('Usage: npm run deploy -- --dry-run | npm run deploy -- --confirm');
if (argv.includes('--dry-run') && argv.includes('--confirm')) fail('Choose --dry-run or --confirm, not both');
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') };
}
async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
const [ssh, ...sshOptions] = sshTarget(host, identity);
const sourceStage = `${projectRoot}/staging/source-${sha}`;
const distStage = `${projectRoot}/staging/dist-${sha}`;
const remoteManifest = `/root/aia-site-${sha}.manifest.json`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]);
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
const transfer = (local, remote, remove) => {
const args = rsyncArguments(local, remote, { remove });
args.splice(-2, 0, '-e', transport);
run('rsync', args);
};
transfer(source, `${host}:${sourceStage}`, true);
transfer(dist, `${host}:${distStage}`, true);
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, manifest, `${host}:${remoteManifest}`]);
run(ssh, [...sshOptions, host, `aia-website-release verify ${distStage} ${remoteManifest} && aia-website-release sync-source ${sourceStage} ${sha} && aia-website-release promote ${distStage} ${remoteManifest} && readlink ${projectRoot}/www/dist`]);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sha = assertPreflight({ requirePrimary: !options.dryRun });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
try {
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE;
const portal = configured ? { file: configured, payload: await payloadFrom(configured), synthetic: false } : await syntheticPayload(temporary);
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'off', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
await executeBuild(env);
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload)));
const artifact = await verifyArtifact(path.join(toolRoot, 'dist'), 'indexing', 'live', { mode: portal.synthetic ? 'synthetic' : 'production', sha256: portal.synthetic ? undefined : portalSha256 });
if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`);
const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha);
const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`);
await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
const snapshot = await createSourceSnapshot(temporary, sha);
const summary = { sourceCommit: sha, treeSha256: manifest.treeSha256, files: Object.keys(manifest.files).length, directories: manifest.directories.length, sourceFiles: snapshot.included, intentionallyExcludedSourceFiles: snapshot.excluded, portal: portal.synthetic ? 'synthetic-dry-run-only' : 'approved-production-ciphertext', destination: projectRoot };
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2)); return; }
await remoteDeploy({ host: process.env.AIA_DEPLOY_HOST, identity: process.env.AIA_DEPLOY_SSH_IDENTITY, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile });
console.log(JSON.stringify({ promoted: true, ...summary }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(`Deployment stopped: ${error.message}`); process.exitCode = 1; });
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs';
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
async function writeContractArtifact(root) {
for (const [relative, contents] of Object.entries(fixture.input.files)) {
const target = path.join(root, relative);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, contents);
}
}
test('site manifest follows the pinned merged-Contabo release contract and detects unsafe public output', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-'));
try {
await writeContractArtifact(root);
const manifest = await releaseManifest(root, fixture.input.sourceCommit);
assert.deepEqual(manifest, fixture.expected);
const serverTool = process.env.AIA_CONTABO_RELEASE_TOOL;
if (serverTool) {
const parity = spawnSync('python3', [serverTool, 'manifest', root, fixture.input.sourceCommit], { encoding: 'utf8' });
assert.equal(parity.status, 0, `Configured Contabo release tool failed: ${parity.stderr}`);
assert.deepEqual(manifest, JSON.parse(parity.stdout));
} else {
t.diagnostic(`Live Contabo-tool parity was not run; this test validates the checked-in schema fixture generated from ${fixture.contaboCommit}. Set AIA_CONTABO_RELEASE_TOOL to the merged website-release.py path for direct parity.`);
}
await fs.mkdir(path.join(root, 'private'));
await assert.rejects(releaseManifest(root, fixture.input.sourceCommit), /Unexpected private artifact/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('deployment transfer uses the Contabo ownership and mode interface', () => {
assert.deepEqual(rsyncArguments('/tmp/source', 'host:/srv/stage', { remove: true }).slice(0, 6), ['-rlt','--delete','--links','--chown=root:root','--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=','/tmp/source/']);
assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']);
assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/);
});
@@ -0,0 +1,32 @@
{
"contract": "AIAMarketing/contabo-vps website-release.py manifest schema",
"contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f",
"input": {
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"files": {
"index.html": "<title>Synthetic</title>",
"_astro/entry.abcdefgh.js": "export {}"
}
},
"expected": {
"directories": [
"_astro"
],
"files": {
"_astro/entry.abcdefgh.js": {
"sha256": "f4c5cf9bb78e85f15dc27180260637cf24b2a24bc39e0788783a3accc4dde614",
"size": 9
},
"index.html": {
"sha256": "3a958d616a9a1f0d9229cff90395efb5155029a7f31d018c198c0c7d4cf28989",
"size": 24
}
},
"immutableAssets": [
"_astro/entry.abcdefgh.js"
],
"schema": 1,
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"treeSha256": "4ca2127ce195f1975cbe85fdea800fb3177972afa3a1692794ba403939288694"
}
}
+17 -5
View File
@@ -1075,7 +1075,7 @@ test('Phase 7 privacy page preserves policy sections and reciprocal eligible nav
});
test('Phase 9 off-mode form surfaces localize Spanish without enabling submission', () => {
for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/referrals','/schedule-consultation']) {
for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/schedule-consultation']) {
const $ = load(artifacts.get('staging').html.get(route));
const form = $('[data-aia-form]');
assert.equal(form.length, 1, route);
@@ -1089,10 +1089,22 @@ test('Phase 9 off-mode form surfaces localize Spanish without enabling submissio
assert.match(form.text(), route.startsWith('/es/') ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/);
}
const $ = load(artifacts.get('staging').html.get('/es/contact'));
assert.match($('.contact-map-section').attr('aria-label'), /^Mapa/);
assert.equal($('#aia-contact-map').attr('data-zoom-in'), 'Acercar');
assert.equal($('#aia-contact-map').attr('data-zoom-out'), 'Alejar');
assert.match($('#aia-contact-map').attr('data-tile-alt'), /^Mapa de OpenStreetMap/);
assert.match($('.openstreetmap-section').attr('aria-label'), /^Mapa/);
assert.equal($('#aia-openstreetmap').attr('data-zoom-in'), 'Acercar');
assert.equal($('#aia-openstreetmap').attr('data-zoom-out'), 'Alejar');
assert.match($('#aia-openstreetmap').attr('data-tile-alt'), /^Mapa de OpenStreetMap/);
});
test('referrals retains the existing hosted Jotform CTA without rendering a local form', () => {
for (const mode of ['staging', 'indexing']) {
const $ = load(artifacts.get(mode).html.get('/referrals'));
const link = $('main a').filter((_i, element) => $(element).text().trim() === 'Refer a Client');
assert.equal(link.length, 1, mode);
assert.equal(link.attr('href'), 'https://form.jotform.com/231638510219149');
assert.ok(link.hasClass('content-button'));
assert.equal($('[data-aia-form], [data-jotform-form-a], #form-title').length, 0);
assert.equal($('main form').length, 0);
}
});
test('Phase 7 author labels and Arabic credential isolation preserve archive SEO', () => {
+27 -4
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import fg from 'fast-glob';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const readText = async (file) => fs.readFile(file, 'utf8');
@@ -36,6 +37,12 @@ function hasTerminalSlash(url) {
return pathname !== '/' && pathname.endsWith('/');
}
function portalPayload(html) {
const match = html.match(/<script[^>]*data-portal-payload[^>]*>([\s\S]*?)<\/script>/i);
if (!match || match[1].trim() === 'null') return undefined;
try { return validatePortalPayload(JSON.parse(match[1])); } catch { return null; }
}
const formAContactLandingUrls = new Map([
['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'],
['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'],
@@ -89,9 +96,11 @@ function unsafeSignals(html, allowApprovedFormA = false) {
return signals;
}
export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }) {
if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.');
if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.');
if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.');
const formAEnabled = forms === 'live';
const stagingPolicy = mode === 'staging';
const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map();
@@ -142,7 +151,18 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
}
if (expectedFormAUrl && deferredIframeSources(html).length) errors.push(`${route}: unexpected deferred frame`);
if (expectedDeferredFormAUrl && iframeSources(html).length) errors.push(`${route}: drawer must not load before activation`);
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`);
const payload = portalPayload(html);
if (payload === null) errors.push(`${route}: malformed portal ciphertext`);
if (payload && route !== '/employee-portal') errors.push(`${route}: portal ciphertext appears outside /employee-portal`);
if (route === '/employee-portal') {
if (portal.mode === 'none' && payload) errors.push(`${route}: private portal ciphertext is not approved for this artifact`);
if (portal.mode === 'production' && !payload) errors.push(`${route}: approved production portal ciphertext is missing`);
if (portal.mode === 'synthetic' && !payload) errors.push(`${route}: synthetic portal ciphertext is missing`);
if (payload && portal.mode === 'production') {
const actual = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
if (actual !== portal.sha256) errors.push(`${route}: portal ciphertext does not match the approved build input`);
}
}
if (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`);
@@ -199,7 +219,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
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 (/(?:^|\/)(?:private|hubfs|staff-resources|management-resources)(?:\/|$)/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);
@@ -221,6 +241,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
return {
mode,
forms,
portal: portal.mode,
htmlRoutes: htmlFiles.length,
sitemapUrls: sitemapRoutes.length,
hreflangLinks,
@@ -241,7 +262,9 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'staging';
const root = process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'dist';
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
const result = await verifyArtifact(path.resolve(root), mode, forms);
const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none';
const portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length);
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 });
console.log(JSON.stringify(result, null, 2));
if (!result.passed) process.exitCode = 1;
}
+23 -1
View File
@@ -4,6 +4,8 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
import { createHash } from 'node:crypto';
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
async function fixture(mode = 'staging', forms = 'live') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-'));
@@ -139,10 +141,30 @@ test('release verification rejects private portal resources and encrypted payloa
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('portal ciphertext')));
assert.ok(result.errors.some((error) => error.includes('private/legacy')));
});
test('approved production portal accepts only the expected two-stream ciphertext on the noindex route', async () => {
const root = await fixture('indexing');
const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('staff', '<p>Synthetic staff</p>', 'synthetic-staff-code'),
await encryptPortalStream('management', '<p>Synthetic management</p>', 'synthetic-management-code'),
] };
const portal = path.join(root, 'employee-portal/index.html');
await fs.mkdir(path.dirname(portal), { recursive: true });
await fs.writeFile(portal, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(payload) + '</script></body></html>');
const sha256 = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
const result = await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 });
assert.equal(result.passed, true, result.errors.join('; '));
await fs.appendFile(portal, '<!-- changed -->');
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, true);
const corrupted = JSON.parse(JSON.stringify(payload));
corrupted.streams[0].ct = (corrupted.streams[0].ct[0] === 'A' ? 'B' : 'A') + corrupted.streams[0].ct.slice(1);
await fs.writeFile(portal, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(corrupted) + '</script></body></html>');
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, false);
});
test('artifact rejects extra routes, wrong origins and prematurely loaded drawers', async () => {
const root = await fixture();
+3 -3
View File
@@ -45,12 +45,12 @@ test('Form A meeting preview requires explicit mock mode and remains staging-onl
assert.equal(formAPreviewEnabled('TRUE', 'FALSE', 'mock'), true);
});
test('form mapping covers twelve native instances and five landing journeys', () => {
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 12);
test('form mapping covers eleven native instances and five landing journeys', () => {
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 11);
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'landing-journey').length, 5);
assert.equal(logicalFormForRoute('/contact'), 'general');
assert.equal(logicalFormForRoute('/es/client-consultation'), 'service');
assert.equal(logicalFormForRoute('/referrals'), 'referral');
assert.equal(logicalFormForRoute('/referrals'), undefined);
for (const route of ['/es/aba-therapy', '/es/autism-evaluations', '/es/learner-social-club']) {
assert.equal(logicalFormForRoute(route), 'service');
}
+128
View File
@@ -0,0 +1,128 @@
#!/bin/sh
# Root-only Contabo-side installer for a reviewed employee-resource plan.
# It deliberately uses the NixOS base shell/coreutils path, not Node.js.
set -eu
fail() { printf '%s\n' "Employee resource installation stopped: $*" >&2; exit 1; }
ROOT=/srv/www/azinstitute4autism.com
ROLES='staff management'
PATH=/run/current-system/sw/bin:/usr/bin:/bin
# The production SSH command uses env -i, so this hook is reachable only from
# the local synthetic-interface test. It never changes the deployed PATH.
case "${AIA_PORTAL_INSTALL_TEST_PATH:-}" in
'') ;;
*/aia-portal-install-*/fake-bin) PATH="$AIA_PORTAL_INSTALL_TEST_PATH:$PATH" ;;
*) fail 'Invalid synthetic installer test path' ;;
esac
export PATH
safe_name() {
value=$1
case "$value" in ''|.|..|.*|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#value}" -le 241 ]
}
valid_hex() {
value=$1 length=$2
[ "${#value}" -eq "$length" ] && printf '%s' "$value" | grep -Eq '^[0-9a-f]+$'
}
regular_file() { [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %h "$1")" -eq 1 ]; }
digest() {
result=$(sha256sum "$1") || return 1
printf '%s\n' "${result%% *}"
}
cleanup() {
status=$?
[ -z "${temporary:-}" ] || rm -rf "$temporary"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
[ "$(id -u)" -eq 0 ] || fail 'Resource installation requires root'
[ "$#" -eq 2 ] || fail 'Usage: portal-resource-install.sh STAGING_DIRECTORY REVIEWED_PLAN.tsv'
stage=$1
plan_file=$2
regular_file "$plan_file" || fail 'Resource plan must be a bounded regular file'
[ "$(stat -c %s "$plan_file")" -le 2000000 ] || fail 'Resource plan is too large'
IFS= read -r header < "$plan_file" || fail 'Resource plan is empty'
[ "$header" = 'AIA_PORTAL_RESOURCE_PLAN_V1' ] || fail 'Unsupported resource plan schema'
commit_line=$(sed -n '2p' "$plan_file")
[ -n "$commit_line" ] || fail 'Resource plan has no source commit'
case "$commit_line" in sourceCommit=*) source_commit=${commit_line#sourceCommit=} ;; *) fail 'Resource plan source commit is invalid' ;; esac
valid_hex "$source_commit" 40 || fail 'Resource plan source commit is invalid'
expected_stage="$ROOT/staging/portal-resources-$source_commit"
[ "$(readlink -f -- "$stage")" = "$expected_stage" ] || fail 'Resource stage is outside the approved root-controlled staging directory'
[ -d "$stage" ] && [ ! -L "$stage" ] || fail 'Resource stage is unsafe'
temporary=$(mktemp -d)
destinations="$temporary/destinations"
staged="$temporary/staged"
: > "$destinations"
: > "$staged"
sed -n '3,$p' "$plan_file" > "$temporary/records"
count=0
line=2
tab=$(printf '\t')
while IFS= read -r record || [ -n "$record" ]; do
line=$((line + 1))
[ -n "$record" ] || fail "Empty resource plan line $line"
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
[ "$kind" = file ] && [ -z "${extra:-}" ] || fail "Invalid resource plan line $line"
case "$role" in staff|management) ;; *) fail "Invalid resource role on line $line" ;; esac
safe_name "$name" && safe_name "$stage_name" || fail "Unsafe resource path on line $line"
valid_hex "$resource_hash" 64 || fail "Invalid resource digest on line $line"
case "$size" in ''|*[!0-9]*) fail "Invalid resource size on line $line" ;; esac
[ "$size" -gt 0 ] && [ "$size" -le 52428800 ] || fail "Invalid resource size on line $line"
destination="$role/$name"
staged_name="$role/$stage_name"
grep -Fqx -- "$destination" "$destinations" && fail "Duplicate resource destination"
grep -Fqx -- "$staged_name" "$staged" && fail "Duplicate staged resource"
printf '%s\n' "$destination" >> "$destinations"
printf '%s\n' "$staged_name" >> "$staged"
count=$((count + 1))
done < "$temporary/records"
[ "$count" -gt 0 ] || fail 'Resource plan has no files'
for role in $ROLES; do
directory="$stage/$role"
[ -d "$directory" ] && [ ! -L "$directory" ] || fail "Missing safe $role staging directory"
find "$directory" -mindepth 1 -print > "$temporary/actual-$role"
while IFS= read -r source; do
relative=${source#"$stage/"}
grep -Fqx -- "$relative" "$staged" || fail "Unexpected staged resource: $relative"
regular_file "$source" || fail "Unsafe staged resource: $relative"
done < "$temporary/actual-$role"
done
# Re-read only validated plan rows, verify every listed input, then install with
# root:nginx ownership and 0640 permissions. mv -n prevents replacement races.
installed=0
while IFS= read -r record || [ -n "$record" ]; do
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
source="$stage/$role/$stage_name"
regular_file "$source" || fail "Missing staged resource: $role/$name"
[ "$(stat -c %s "$source")" -eq "$size" ] && [ "$(digest "$source")" = "$resource_hash" ] || fail "Staged resource digest mismatch: $role/$name"
destination_directory="$ROOT/secure-resources/$role"
destination="$destination_directory/$name"
[ -d "$destination_directory" ] && [ ! -L "$destination_directory" ] || fail "Protected destination directory is unsafe: $role"
if [ -e "$destination" ] || [ -L "$destination" ]; then
regular_file "$destination" || fail "Unsafe existing resource destination: $role/$name"
[ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Refusing to replace existing resource: $role/$name"
printf '%s\n' "already-verified $role/$name"
installed=$((installed + 1))
continue
fi
next="$destination_directory/.${name}.next-$$"
[ ! -e "$next" ] && [ ! -L "$next" ] || fail "Temporary destination already exists: $role/$name"
install -o root -g nginx -m 0640 "$source" "$next" || fail "Unable to stage protected resource: $role/$name"
mv -n -- "$next" "$destination" || fail "Unable to install protected resource: $role/$name"
regular_file "$destination" && [ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Installed resource verification failed: $role/$name"
printf '%s\n' "installed $role/$name"
installed=$((installed + 1))
done < "$temporary/records"
printf '%s\n' "sourceCommit=$source_commit installed=$installed"
+6 -1
View File
@@ -548,7 +548,7 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
['/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'],
['/referrals', 'Refer a Client', 'https://form.jotform.com/231638510219149'],
['/aba-therapy-intake-process', 'Take the First Step', '/client-consultation'],
['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'],
['/services', 'Consult with a Client Advocate', '/client-consultation'],
@@ -563,6 +563,11 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
assert.equal(link.length, 1, `${route}: ${label}`);
assert.equal(link.attr('href'), href, route);
const target = new URL(href, site + route);
if (target.origin !== new URL(site).origin) {
assert.equal(route, '/referrals', 'only the explicitly approved referral CTA leaves the site');
assert.equal(target.href, 'https://form.jotform.com/231638510219149');
continue;
}
const destination = page(target.pathname);
if (target.hash) assert.equal(destination(`[id="${target.hash.slice(1)}"]`).length, 1, href);
}
+21 -8
View File
@@ -11,16 +11,18 @@ import { collectResource, resourceReferences, rewriteResources } from './portal-
import { isFormACompletion } from '../src/utils/jotform-completion.ts';
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
test('completion envelope requires exact origin, active frame window and string Form A ID', () => {
test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => {
const source = {};
const check = (event, frame, active) => isFormACompletion(event, frame, active,
'https://form.jotform.com/262946078314059?formContext=consultation&language=en');
const data = { action: 'submission-completed', formID: '262946078314059' };
const event = { origin: 'https://form.jotform.com', source, data };
assert.equal(check(event, source, true), true);
assert.equal(check({ ...event, origin: 'https://hipaa-submit.jotform.com' }, source, true), true);
assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true);
for (const change of [
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'https://hipaa-submit.jotform.com.evil.invalid' },
{ origin: 'https://other.jotform.com' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
{ source: {} }, { source: null }, { data: { ...data, formID: 'other' } },
{ data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } },
{ data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) },
@@ -54,7 +56,13 @@ test('staff template presentation follows sanitized headings, not original HubSp
assert.match(renderer, /templatesList\?\.classList\.add\('portal-template-grid'\)/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(css, /@media \(max-width: 767px\)/);
assert.match(css, /ul\.portal-template-grid a\.portal-resource-button/);
assert.doesNotMatch(css, /ul\.portal-template-grid a\.portal-resource-button/);
assert.match(renderer, /items\.every\(\(item\) => item\.querySelector\('a\.portal-resource-button'\)\)/);
assert.match(renderer, /list\.classList\.add\('portal-resource-list'\)/);
assert.match(css, /\.portal-resource-list > li::marker\)\s*\{\s*content: none;/);
assert.match(css, /\.employee-portal__content:focus\s*\{ outline: none;/);
assert.match(css, /input:focus-visible\s*\{ outline: 3px solid/);
assert.match(css, /:global\(a:focus-visible\)\s*\{ outline: 3px solid/);
});
test('global preference validates expiry, corrupted values and clock anomalies', () => {
@@ -119,21 +127,26 @@ test('private collector validates binary, reuses hash, rejects errors and unappr
} finally { await fs.rm(dir, { recursive: true }); }
});
test('portal input requires every local gate and rejects missing or malformed ciphertext', async () => {
test('portal input isolates local preview from explicit indexing production payloads', 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', file };
const options = { localApproved: 'true', indexing: 'false', localFile: file };
assert.ok(readPortalInput(options));
// Form modes cannot substitute for the independent private-input approval.
for (const mode of ['live', 'off', 'mock']) {
assert.ok(readPortalInput({ ...options, mode }));
assert.equal(readPortalInput({ indexing: 'false', file, mode }), undefined);
assert.equal(readPortalInput({ indexing: 'false', localFile: file, mode }), undefined);
}
for (const changed of [{ approved: undefined }, { approved: 'false' }, { indexing: 'true' }, { indexing: undefined }, { file: '/missing-fixture' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), undefined);
for (const changed of [{ localApproved: undefined }, { localApproved: 'false' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
assert.throws(() => readPortalInput({ ...options, indexing: 'true' }), /publication mode/);
assert.ok(readPortalInput({ productionApproved: 'true', indexing: 'true', productionFile: file }));
assert.throws(() => readPortalInput({ productionApproved: 'true', indexing: 'true' }), /required/);
assert.throws(() => readPortalInput({ localApproved: 'true', productionApproved: 'true', indexing: 'false', localFile: file, productionFile: file }), /either/);
await fs.writeFile(file, '<h1>Not encrypted</h1>');
assert.throws(() => readPortalInput(options), /missing or malformed/);
} finally { await fs.rm(dir, { recursive: true }); }
});