Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d417a422 | ||
|
|
4987f5ee1a | ||
|
|
362b984753 | ||
|
|
ba93a54942 | ||
|
|
5fa54719b2 | ||
|
|
e224616b99 | ||
|
|
a72470459a | ||
|
|
80e0c405bc | ||
|
|
5c8ba2e11e | ||
|
|
85def8466c | ||
|
|
b5e606a839 | ||
|
|
8dff6a5ad0 |
No files matched your search
+4
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '© <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,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: '© <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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Executable
+128
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
Reference in new issue
Block a user