Author SHA1 Message Date
DeCentN2Madness a643383e79 feat(deploy): support positional release inputs 2026-11-07 14:02:35 -07:00
DeCentN2Madness 2fca442922 fix(seo): add .html to privacy policy berkeley phi info link 2026-11-07 02:06:25 -07:00
DeCentN2Madness e002427aef fix(seo): noindex early intervention landing page 2026-11-07 01:49:58 -07:00
DeCentN2Madness eeff641a1d feat(blog): limit rapid repeat views 2026-11-06 14:05:37 -07:00
DeCentN2Madness 96288b1eb8 style(blog): strengthen accent treatments 2026-11-06 13:35:41 -07:00
DeCentN2Madness 8c19266760 feat(blog): enable live likes and first-party views 2026-11-06 10:54:18 -07:00
DeCentN2Madness 89cdb00765 fix(portal): preserve readable download filenames 2026-11-05 19:21:26 -07:00
DeCentN2Madness 596246f20a feat(portal): reuse unlock code for protected downloads 2026-11-05 19:21:26 -07:00
DeCentN2Madness 475dd3379b fix(deploy): avoid source guard self-match 2026-11-05 13:29:38 -07:00
DeCentN2Madness 81492125f4 fix(portal): prepare approved resource mappings safely 2026-11-05 00:40:18 -07:00
DeCentN2Madness 36d417a422 fix(deploy): make portal installer runtime portable 2026-11-05 00:40:18 -07:00
DeCentN2Madness 4987f5ee1a fix(deploy): isolate portal gate from fixture validation 2026-11-05 00:40:18 -07:00
DeCentN2Madness 362b984753 fix(deploy): detect SSH tooling without connecting 2026-11-05 00:40:18 -07:00
DeCentN2Madness ba93a54942 feat(deploy): add Contabo release workflow and portal payload gate 2026-11-05 00:40:18 -07:00
DeCentN2Madness 5fa54719b2 docs(nginx): import HubSpot redirect rules 2026-11-02 00:01:38 -07:00
DeCentN2Madness e224616b99 fix(forms): accept HIPAA Jotform completion origin 2026-11-01 19:16:19 -07:00
DeCentN2Madness a72470459a docs(referrals): record owner-approved existing Jotform cutover workflow 2026-11-01 18:10:57 -07:00
DeCentN2Madness 80e0c405bc docs(referrals): retire Form C plan and document existing hosted workflow 2026-11-01 18:10:54 -07:00
DeCentN2Madness 5c8ba2e11e fix(referrals): restore existing hosted referral CTA and remove native placeholder 2026-11-01 18:10:51 -07:00
DeCentN2Madness 85def8466c refactor(maps): share localized OpenStreetMap component 2026-11-01 17:48:38 -07:00
DeCentN2Madness b5e606a839 fix(tour): restore location map and schedule button 2026-11-01 16:41:20 -07:00
DeCentN2Madness 8dff6a5ad0 style(portal): refine resource links, list markers and focus styling
Remove forced link underlines and suppress bullets and numbering only on
staff lists whose items contain resource buttons. Keep the responsive
Management Templates grid while allowing its buttons to use their natural
size instead of stretching to fill each cell.

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

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

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@
packages = developmentPackages ++ [ pkgs.playwright-driver ]; packages = developmentPackages ++ [ pkgs.playwright-driver ];
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs"; PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers { PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
withFirefox = false; withFirefox = true;
withWebkit = false; withWebkit = false;
withFfmpeg = false; withFfmpeg = false;
}; };
+4 -1
View File
@@ -10,7 +10,10 @@ PUBLIC_AIA_FORMS_MOCK_RESULT=success
PUBLIC_AIA_FORM_A_PREVIEW=false PUBLIC_AIA_FORM_A_PREVIEW=false
PUBLIC_ALLOW_INDEXING=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. # Never supply a passphrase through environment variables or build arguments.
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false
# AIA_PORTAL_CIPHERTEXT_FILE=/owner-controlled/private/ciphertext.json # 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
+43 -3
View File
@@ -83,6 +83,8 @@ The default values are:
```txt ```txt
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
PUBLIC_COUNTERS_MODE=off
PUBLIC_COUNTERS_LIVE_APPROVED=false
PUBLIC_ALLOW_INDEXING=false PUBLIC_ALLOW_INDEXING=false
``` ```
@@ -94,6 +96,16 @@ not deploy anything or change environment files. Run `npm run build` afterward
to restore staging output. Only explicitly authorized production deployments to restore staging output. Only explicitly authorized production deployments
should publish indexing-enabled artifacts. should publish indexing-enabled artifacts.
Ordinary local builds keep counters off. `npm run deploy` deliberately and
non-overridably builds the production artifact with live counters, the approved
API origin and `PUBLIC_COUNTERS_LIVE_APPROVED=true`. Article pages post one
first-party view per real navigation; Library cards only batch-read statistics.
Likes use the existing POST/DELETE API and store only the visitor's chosen slug
set in localStorage. No view cookies or visitor identifiers are created. Run
`npm run test:counters`, then build in live mode and run `npm run
verify:counters -- --browser=chromium` (or `firefox`) for a fully intercepted
browser regression that cannot write production counters.
## View And Build ## View And Build
### Development Server ### Development Server
@@ -580,6 +592,15 @@ npm run audit:links
The live crawl is optional and may fail in restricted network environments. 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 ## Forms
As of the owner decision on 2026-10-30, `npm run dev` and `npm run build` As of the owner decision on 2026-10-30, `npm run dev` and `npm run build`
@@ -591,16 +612,35 @@ Use `PUBLIC_AIA_FORMS_MODE=off npm run build` for an offline artifact, or
`PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty `PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty
mode means live; obsolete `isolated-test` and other invalid values produce an mode means live; obsolete `isolated-test` and other invalid values produce an
explicit configuration error. Remove old approval/environment variables from explicit configuration error. Remove old approval/environment variables from
local settings. Indexing is independent of forms; counters remain off by default. local settings. Indexing and form mode remain independent of counter mode.
See the [current Form A guide](integrations/jotform/README.md) for the exact See the [current Form A guide](integrations/jotform/README.md) for the exact
registry, safeguards, rollback and owner acceptance requirements. Form C remains registry, safeguards, rollback and owner acceptance requirements. Form C remains
inactive. Employee-portal private input retains its own separate gates. inactive. Employee-portal private input retains its own separate gates.
Verify matching output with Verify matching output with
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live`; `node tools/phase10-artifact-verify.mjs --mode=staging --forms=live --counters=off`;
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock` use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
for those explicit builds. No actual submissions run in automated tests. for those explicit builds. Production counter artifacts require
`--counters=live`. No actual submissions or production counter writes 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. After a successful unlock, the page keeps that role's code
only in memory and uses it for same-origin authenticated document fetches. The
response is downloaded as a local Blob, so employees do not receive a second
browser authentication prompt. Cross-role requests are stopped before
credentials are sent; direct resource URLs still require nginx authentication.
Codes are never put in URLs or browser storage. The accepted interim portal is
explicitly non-PHI.
### Google Ads consultation-form conversion handoff ### Google Ads consultation-form conversion handoff
+88 -47
View File
@@ -1,77 +1,118 @@
# Employee portal compatibility and private migration # Employee portal compatibility and private migration
Recorded 2026-10-30. Browser-side shared-code decryption is approved only for Recorded 2026-11-04. The clinical director accepted the collected, non-PHI
local compatibility development. It is not authentication, individual revocation, staff and management portal content and the interim shared-code presentation.
a secure session or a HIPAA compliance determination. Resources need separate Browser-side decryption remains access friction rather than individual
server authorization before cutover. authentication, revocation, a secure session, or a HIPAA determination.
The same successfully validated code authorizes protected document downloads
for that role during the current page session. The browser keeps it only in a
closure, performs a same-origin authenticated fetch, and saves the returned
document without opening nginx's native Basic Auth dialog. Staff uses the
`staff` username and stream `b`; management uses the `management` username and
stream `a`. Direct URLs remain nginx-protected, cross-role fetches are rejected
before credentials are sent, and failed authenticated downloads show an inline
error without direct-navigation fallback. No code enters a URL, browser
storage, public artifact, or log. Downloads use a bounded safe form of the
approved link label and preserve the validated extension from the protected
SHA-256 server destination; server resources are not renamed.
## 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 ## Source and private collection
Read-only legacy footer source: The 42 approved non-PHI resources have already been collected under the ignored
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`. `private/employee-portal/hubfs/` workspace. Collection and clinical review are
The original stays outside Git as rollback evidence. No access code was available complete. Do not run a new collection pass for production preparation.
to the agent. The exact resource count and real content compatibility remain
unverified. Do not infer the earlier approximate 43 links as a measured count.
From `www`, in the owner's private interactive terminal: Production preparation is an owner-only, hidden-prompt sequence. The first
command identifies the encrypted streams from the staff and management codes;
the codes are never accepted in arguments, environment variables, files or
output:
```sh ```sh
node tools/employee-portal-migrate.mjs collect \ npm run portal:prepare -- identify \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- propose-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt /home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
``` ```
Enter each legitimate view code at its hidden prompt. Never place codes in Review the private mapping summary. The role rule maps staff-only references to
arguments, environment variables, shell history or reports. The utility checks `/staff-resources/`, management-only references to `/management-resources/`, and
both streams, keeps plaintext in memory, and collects only referenced `/hubfs/` duplicates a shared source into both protected namespaces. After the owner
resources from the verified source origin. Other origins require explicit approves that operational policy, run:
review/code allowlisting; redirects are bounded and never inherit credentials.
No Jotform, Google or other vendor forms are fetched. ```sh
npm run portal:prepare -- approve-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- rewrite-production \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
The rewrite command prompts for both codes again, rewrites every approved legacy
resource URL, re-encrypts with fresh salts and IVs, verifies each role privately,
and atomically installs `approved-production-ciphertext.json` plus a matching
private `production-readiness.json`. It refuses unresolved `/hubfs/` references.
Prior manifests and production ciphertext are retained as timestamped private
backups.
### Mapping approval versus server verification
`mappingApproved`, `accessPolicyApproved`, and `rewriteApproved` mean the
verified collected source has an approved role and safe protected destination
and may be prepared or installed. They do not claim the destination exists.
`destinationInstalled`, `destinationHashVerified`,
`destinationPermissionsVerified`, and `destinationAccessVerified` remain false
until an authorized VPS rehearsal actually installs the file and verifies its
bytes, `root:nginx` permissions, Basic Auth boundary and protected URL. Local
preparation and installation accept the first state; release acceptance must
still complete the second state before browser cutover.
Read-only legacy footer source:
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`.
The original stays outside Git as rollback evidence. The 42-file collection and
its accepted content are complete; no current command downloads resources.
Private output is `private/employee-portal/`: directories 0700, manifests and Private output is `private/employee-portal/`: directories 0700, manifests and
binary resources 0600. This exact subtree is gitignored and outside Astro's binary resources 0600. This exact subtree is gitignored and outside Astro's
public directory. Filenames are SHA-256 hashes of source URLs, independently public directory. Filenames are SHA-256 hashes of source URLs, independently
verified by content hashes. The manifest retains original references, labels, verified by content hashes. The manifest retains original references, labels,
view membership, MIME, bytes, status and delivery/classification decisions. view membership, MIME, bytes, status and delivery/classification decisions.
All new resources default to UNRESOLVED. Publicly reachable does not mean public The manifest preserves the original source URL, collection hash, size, reference
approved. Resuming verifies an existing file's content hash; mismatches are not membership and accepted source evidence. Public legacy reachability did not
overwritten. HTML error documents, executable responses, invalid known signatures, infer public approval.
redirects outside approved destinations and resources over 50 MiB are rejected.
The first pass allows only the verified site origin. If a legitimate `/hubfs/`
response redirects to a HubSpot CDN, inspect that exact HTTPS origin first, then
add its origin to the private manifest's `approvedOrigins` list and rerun. Never
approve a redirect based only on a failed download or a broadly shared vendor
domain. Failures remain private review items. The utility prints counts only.
Before every artifact transfer, verify that only `dist` is copied and that it Before every artifact transfer, verify that only `dist` is copied and that it
contains no private manifest, document or real ciphertext unless that ciphertext contains no private manifest, document or real ciphertext unless that ciphertext
has separately been authorized for the local preview. Never archive the whole has separately been authorized for the local preview. Never archive the whole
working directory as a deployable artifact. working directory as a deployable artifact.
## Approved rewriting ## Historical collection and rewriting recovery notes
The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL, The original source, collection manifests, collected bytes and prior ciphertext
MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`, revisions remain recovery evidence. Do not change their classifications or
`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for destination flags by hand; use the production-preparation sequence above so
internal documents) `destinationAccessControlled` only with actual evidence. stream identity, references, source hashes and protected destinations remain
The utility does not create those destinations or infer their approval. consistent. CSS/srcset dependencies remain an explicit error rather than a
silent rewrite.
```sh
node tools/employee-portal-migrate.mjs rewrite \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
It reauthenticates both views, changes approved quoted href/src attributes only,
preserves unrelated HTML/labels, and re-encrypts with fresh salts and IVs. It
verifies round-trip decryption and distinct codes before writing new ciphertext
with exclusive filenames. The original source is untouched. CSS/srcset mappings
require separate review and are rejected rather than silently rewritten.
Unresolved links retain their original destination as a recorded dependency,
not a claim of verified destination health. A changed manifest alone never
changes links in the encrypted stream.
## Local preview and content safety ## Local preview and content safety
Use a private ciphertext file generated by `collect` or `rewrite`: Use a retained private ciphertext revision:
```sh ```sh
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \ AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \
+15 -3
View File
@@ -57,8 +57,11 @@ Every URL has exactly `formContext` and `language` parameters:
| consultation | `/client-consultation` (en), `/es/client-consultation` (es), `/schedule-consultation` (en) | Immediate, 3 | | consultation | `/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 | | 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 The context is presentation-only. Physical Form B is retired. No Form C is planned.
`/referrals` remain inactive; other routes cannot acquire Form A by setting a mode. `/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. The typed future Academy service copy does not publish another page.
Live service drawers open at four seconds OR 50% scroll, canceling the unused 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 Employee-portal ciphertext remains separately gated by its own approval, private
input and non-indexing preview requirement. Form A modes cannot authorize it. 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 ## 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: 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 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. - [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. - [ ] 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. - [ ] The hidden honeypot remains present, effective, and excluded from user-facing visual changes.
- [ ] Exactly one native Submit button is visible for every valid context. - [ ] Exactly one native Submit button is visible for every valid context.
+7 -7
View File
@@ -63,10 +63,10 @@ Test direct requests, alternate encodings, role crossover and rollback.
- Portal HTML: shared-code browser decryption, offline-guessable access friction. - Portal HTML: shared-code browser decryption, offline-guessable access friction.
- Documents: require server authorization even if URLs are encrypted in the UI. - Documents: require server authorization even if URLs are encrypted in the UI.
The compatibility preview cannot grant production portal access. Before cutover, The approved production portal build uses a non-public encrypted two-stream
classify every document, implement and verify its delivery destination, rewrite payload and retains the same browser-side decryption boundary. Approved non-PHI
and re-encrypt links, test two-view separation, rotate shared codes privately, documents install separately into the VPS-owned staff/management directories;
check the public artifact for private files, and obtain owner/security approval. they are never copied into `dist`. Verify both protected namespaces, two-view
Back up encrypted configuration and documents separately from keys; rehearse separation, artifact exclusions, code rotation, restore and rollback before
restore and rollback. Revoking a static release cannot retract ciphertext or cutover. Revoking a static release cannot retract ciphertext or files already
files already downloaded by visitors. Site cutover is still unauthorized. downloaded by visitors. Site cutover remains separately authorized.
+135
View File
@@ -0,0 +1,135 @@
# 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.
- Either an OpenSSH host alias already configured locally, or a normal SSH
identity accepted by the existing root receiver.
- For environment-only invocations: `AIA_DEPLOY_HOST=administrator@host`,
`AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`, and
`AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`.
- For positional invocations, use either `SSH_HOST CIPHERTEXT` (OpenSSH resolves
the host alias and identity) or `SSH_HOST SSH_IDENTITY CIPHERTEXT` (the CLI
supplies `-i` and `IdentitiesOnly=yes`).
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
# Use a configured OpenSSH host alias and a private ciphertext path.
npm run deploy -- --dry-run aiar \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
# Supply an explicit SSH identity; paths may contain spaces.
npm run deploy -- --dry-run 'root@66.94.121.180' \
"$HOME/.ssh/AIAMarketing" \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
```
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 equivalent confirmed positional commands are:
```sh
npm run deploy -- --confirm aiar \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
npm run deploy -- --confirm 'root@66.94.121.180' \
"$HOME/.ssh/AIAMarketing" \
"$PWD/private/employee-portal/approved-production-ciphertext.json"
```
When positional values are present, they take precedence over all three
deployment environment variables. Two positional values deliberately omit
`-i`, allowing the local OpenSSH configuration to select the identity. Dry-run
never opens SSH or rsync, even when host and identity arguments are provided.
Invalid modes, mixed confirmation flags, incomplete positional sets, unsafe
hosts, relative positional paths, and missing ciphertext files fail before a
release can be promoted.
The confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and
the approved live counters configuration, 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 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
```
For the first install, the client extracts `portal-resource-install.sh` from the
current reviewed Git commit, uploads it into the root-controlled commit staging
directory, verifies its SHA-256 remotely, and invokes it through NixOS's base
`/bin/sh` and coreutils path. A prior public site release and a globally installed
Node.js runtime are not prerequisites. 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.
Local mapping approval authorizes preparation and installation only. After the
authorized VPS rehearsal, verify installed hashes, ownership/mode, staff versus
management Basic Auth, and both protected URLs before deploying portal links.
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.
The portal uses one code entry per page session. A successful decrypt retains
the code only in page memory and sends the matching fixed username (`staff` or
`management`) in an explicit same-origin Basic Authorization header when a
protected link is clicked. It downloads the authenticated response as a Blob
and never navigates directly to the protected URL after an error. Verify that
ordinary clicks download without a native credential dialog, inline errors
appear for denied or unavailable files, direct unauthenticated requests remain
401, and neither role can request the other namespace. Reloading, leaving the
page, or an Astro page swap discards the in-memory credential.
+6
View File
@@ -10,6 +10,10 @@
"check": "astro check", "check": "astro check",
"build": "astro check && astro build", "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", "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",
"portal:prepare": "node tools/employee-portal-migrate.mjs",
"preview": "astro preview", "preview": "astro preview",
"extract:blog-footers": "node tools/extract-blog-footers.mjs", "extract:blog-footers": "node tools/extract-blog-footers.mjs",
"crawl:live": "node tools/crawl-live-site.mjs", "crawl:live": "node tools/crawl-live-site.mjs",
@@ -23,6 +27,8 @@
"test:seo": "node --experimental-strip-types --test tools/seo.test.mjs", "test:seo": "node --experimental-strip-types --test tools/seo.test.mjs",
"test:library": "node --experimental-strip-types --test tools/library.test.mjs", "test:library": "node --experimental-strip-types --test tools/library.test.mjs",
"test:campaign": "node --test tools/campaign.test.mjs", "test:campaign": "node --test tools/campaign.test.mjs",
"test:counters": "node --experimental-strip-types --test tools/counters.test.mjs",
"verify:counters": "node tools/counters-browser.mjs",
"verify:campaign": "node tools/campaign-browser.mjs", "verify:campaign": "node tools/campaign-browser.mjs",
"generate:sitemap": "node tools/generate-sitemap.mjs", "generate:sitemap": "node tools/generate-sitemap.mjs",
"generate:redirects": "node tools/generate-redirects.mjs" "generate:redirects": "node tools/generate-redirects.mjs"
+59 -4
View File
@@ -1,6 +1,61 @@
# AIA Astro Migration — Living Session Handoff # 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.
### Portal resource preparation correction — 2026-11-04
The resource schema now separates approved source-to-role mapping from actual
VPS installation evidence. An approved mapping may be prepared and installed
while all server-installed/hash/permission/authentication/URL verification flags
remain false. Shared resources can be installed from one verified source into
both protected namespaces without weakening management isolation. The owner-only
private workflow identifies the two encrypted streams via hidden prompts,
derives deterministic role mappings, rewrites all legacy employee `/hubfs/`
links, and creates a hash-bound private readiness record. A real production
ciphertext cannot pass `npm run deploy -- --dry-run` merely because it is
structurally valid.
The first resource install no longer depends on a previously promoted site
release: the reviewed POSIX installer is staged from the exact Git commit and
verified by SHA-256 before root execution. Server installation, SOPS-backed
Basic Auth and protected URL verification remain future VPS-rehearsal gates.
## 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` **Repository:** `AIAMarketing/azinstitute4autism.com`
**Working branch:** `faithful-astro-migration` **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. **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 +81,7 @@ and form environment role are retired, not renamed.
Indexing and forms are independent. Both publication modes emit ten immediate Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS 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, Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for manual CTA override, deferred loading, verified completion acknowledgment for
@@ -213,10 +268,10 @@ trusted-source comparison. No insecure retrieval was repeated.
### Current two-form status — 2026-10-29 ### 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. - 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. - 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. 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`; Normal staging has 128 `noindex,nofollow` pages because it also contains `/lp`;
+37
View File
@@ -4367,3 +4367,40 @@ because credentials were unavailable. No private markup or resources are tracked
The Spanish extra dropdown choices remain a Jotform Builder review item, with The Spanish extra dropdown choices remain a Jotform Builder review item, with
fixed Astro context/locale URLs verified. See fixed Astro context/locale URLs verified. See
[focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md). [focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md).
## 2026-11-01 — Tour map and CTA restoration
Compared the public `/tour` HTML retrieved with normal TLS validation on
2026-11-01 against the migrated page. The source ends with a centered filled
“Schedule a Tour Today” link to `/client-consultation`, followed by a full-width
360px Leaflet 1.9.4 map. Restored the CTA using the existing AIA `Button`
component and added a Tour-only map below the content container.
The map preserves the source view `[33.63, -111.88756]`, zoom 12, clinic marker
`[33.617746, -111.88756]`, OpenStreetMap attribution, 124px logo and directions
popup. Leaflet CSS/JS use the same version and SRI hashes already used by Contact;
tile requests go to `tile.openstreetmap.org`. These are external map dependencies,
not self-hosted assets. No analytics, forms, clinical copy, metadata or routes
changed. The logo is local. Intentional accessibility/reliability improvements:
meaningful region/marker labels, visible link focus, safe new-tab links, correctly
encoded directions destination and address/directions fallback when JavaScript
or Leaflet is unavailable. Existing nine-photo gallery and captions are unchanged.
Validation: Astro check/build passed (128 pages); link audit found zero broken
internal links; five Phase 8 accessibility tests and the focused nine-photo Tour
regression passed. Chromium comparison against the retrieved live HTML and the
local build at 390px and 1440px passed: both maps span the viewport at 360px
height, all 4/12 requested map tiles loaded, marker/directions popup rendered,
and the Astro page had no horizontal overflow or JavaScript exceptions. The
CTA matches source fill/border `#ffbd59`, text `#254080` and no underline, using
the established Astro button proportions rather than copying HubSpot CSS.
No-JavaScript address/directions and consultation-link fallback passed.
Evidence and full-page/map screenshots: `/tmp/aia-tour-review-hknOnA/`.
Read-only map/static asset GETs were allowed; unrelated vendor scripts and
tracking requests were blocked. Consequently the live reference reported missing
jQuery errors, while the Astro page reported none; this was a bounded map/CTA
comparison, not validation of all live HubSpot functionality. An initial test
selector matched both directions and the popup-close link; narrowing it to the
external directions link resolved the test-only failure. No account changes,
submissions, deployment, staging or commit occurred.
+54
View File
@@ -12,6 +12,60 @@ location / {
try_files $uri $uri/index.html $uri.html =404; 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 ^/aba$ /aba-therapy permanent;
rewrite ^/autismevaluations$ /autism-evaluations permanent; rewrite ^/autismevaluations$ /autism-evaluations permanent;
rewrite ^/learnersocialclub$ /learner-social-club permanent; rewrite ^/learnersocialclub$ /learner-social-club permanent;
@@ -1,5 +1,21 @@
# Phase 10 — Technical Readiness Package # 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 Date: 2026-10-22 UTC
Status: Technical handoff prepared; production gates remain open. 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 Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS 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, Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for 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. 1. Record a locked Node/npm/toolchain and reproducible artifact manifest.
2. Obtain sanitized Contabo/NixOS/nginx/TLS/DNS/Cloudflare facts. 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. 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. 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. 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. 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 ## 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 ## Recommended checkpoint boundaries
@@ -1,5 +1,12 @@
# Phase 9 — Decision register # 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. 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 ## 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 ## P9-03 — Form/API ownership
- **Status:** Pending decision - **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) - **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. - **Risk if unresolved:** Lost inquiries, accidental collection, unsafe retention or ambiguous failure.
- **Decision owner role:** AIA intake/operations owner - **Decision owner role:** AIA intake/operations owner
- **Required reviewers:** Privacy/legal/security - **Required reviewers:** Privacy/legal/security
- **Dependencies:** Field necessity, recipient, spam controls, retention/access, failure handling, P9-07/09 - **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 - **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. - **Authorization required:** Explicit owner decision, then a bounded implementation/test scope; live activation/deployment requires separate authorization.
- **Resolution date/evidence:** None; not approved. - **Resolution date/evidence:** None; not approved.
@@ -1,5 +1,21 @@
# P9-03 source-specific implementation plan # 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 ## Current decision — 2026-10-30: default-live Form A
The owner intentionally supersedes the earlier isolated-test and seven-route 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 Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS 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, Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for 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 Earlier dated decisions and test results below are historical evidence, not
current environment setup instructions. 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. **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-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-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-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-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-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 | | 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. - **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. - **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. - **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. - **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 # 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 ## Current decision — 2026-10-30: default-live Form A
The owner intentionally supersedes the earlier isolated-test and seven-route 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 Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS 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, Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for 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 Earlier dated decisions and test results below are historical evidence, not
current environment setup instructions. 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. 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-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-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-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-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-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 | | 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 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-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-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-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-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 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 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-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 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 INV-01,Supplied roster provenance and remaining verification,Complete 27-ID roster received and uniqueness checked; verify uncertain generic titles and relevant operational classification with account owner without inferring usage or retrieving submissions,Jotform account owner,Owner-supplied read-only connector roster; contract v1.2 section 3 lists all 27 unique IDs; individual roster no longer outstanding; new-form configuration and usage verification pending,Pending/Unverified,,Read-only metadata review authorization; no account mutation
1 gate_id subject verification_required owner_role evidence_source status approval_date authorization_required
10 JF-09 Controlled synthetic acceptance Actual EN/ES synthetic submission, notifications, success/failure, redirects and privacy boundary Later separately authorized isolated form/environment; never real patient data Owner-confirmed 2026-10-28 direct-Jotform EN/ES and sampled Astro-iframe synthetic submissions with native thank-you and notification/autoresponder delivery. Functional submission acceptance is complete for the reported samples; exact ABA landing route, all-seven-route runtime/privacy and production-release acceptance remain open; 2026-10-30 supersession: 16 placements are default-live by owner authorization, with remaining all-route runtime/accessibility acceptance separate Owner-verified — Form A functional submission samples 2026-10-28 Separate batch authorization; no action authorized by this checklist
11 OA-01 Intake/operations Minimum fields, required flags/contact preference consistency, follow-up/callback expectations, Tour/fundraising scope Intake/operations Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
12 OA-02 Clinical leadership Eligibility, service/evaluation availability, private-pay Social Club distinction, referral boundary and approved claims Clinical leadership Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
13 OA-03 Jotform account owner JF-01–08 for Form A consultation and Form C, Form C blank-form creation, actual configuration/ID and preservation of all existing forms JF-01–08 for Form A consultation; confirm the existing hosted /referrals Jotform operational configuration and preservation of all existing forms Jotform account owner Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
14 OA-04 English/Spanish reviewer Labels, errors, instructions, confirmations and locale switching; no automatic translation approval English/Spanish reviewer Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
15 OA-05 Privacy/legal/security Protected fields, generic email/headers/recipients, embeds/referrers, vendor flows, access/retention and tracking boundaries Privacy/legal/security Owner-verified 2026-10-27 privacy approval for Form A PHI protection, generic no-PHI notification, no PDF attachment, no webhooks/integrations, recipient access and retention/deletion; embed/referrer runtime review and tracking boundaries remain open Partially verified — Form A configuration, access and retention scope 2026-10-27 Separate batch authorization; no action authorized by this checklist
16 OA-06 Marketing Deferred attribution categories and redesigned successful-submission semantics Marketing Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
18 OA-08 Release owner Phase 10 integration activation, go/no-go, artifact/vendor rollback and deployment Release owner Dated owner approval with scope and sanitized supporting evidence Pending/Unverified Separate batch authorization; no action authorized by this checklist
19 FA-PRD-01 Form A mode and placement contract Superseded 2026-10-30: live is default on 16 fixed placements; explicit off/mock isolate forms; indexing is independent; invalid modes fail clearly Release + hosting owner Owner authorizes public development forms; repository registry/artifact/browser tests; no hosting deployment performed Owner-authorized architecture — runtime/site-release acceptance separate 2026-10-30 No additional Form A build approval flags; site cutover requires separate authorization
20 FA-CONS-01 Form A consultation context Verify fixed `consultation` presentation value, required service-interest question, five approved choices, conditional email/phone behavior, EN/ES translations and no additional collection; no diagnostic-evaluation explanatory note Account + operations + clinical + privacy owners Owner reports connected-app configuration; sanitized Builder configuration and approved synthetic test evidence still required; no ID/condition inferred by repository Owner-reported configured — browser acceptance pending 2026-10-29 Default-live consultation placements owner-authorized 2026-10-30; Jotform runtime/language acceptance and site cutover remain separate
21 FC-01 Form C referral initiation Legacy professional referral link Create only after authorization, then verify minimum professional fields, no patient data instruction, PHI controls, notification/retention and protected-workflow handoff Form C canceled; verify the existing hosted Jotform destination and its form fields privacy recipient notifications accessibility and operational suitability Account + operations + privacy owners Sanitized Form C Builder configuration and approved synthetic test evidence Owner decision 2026-11-01: /referrals links to https://form.jotform.com/231638510219149; no account mutation or real submission in repository change Pending/Unverified Form C canceled — legacy hosted form acceptance pending 2026-11-01 Separate creation and synthetic-test authorization required No Form C creation or embed authorization; separately verify existing hosted form before site release
22 INV-01 Supplied roster provenance and remaining verification Complete 27-ID roster received and uniqueness checked; verify uncertain generic titles and relevant operational classification with account owner without inferring usage or retrieving submissions Jotform account owner Owner-supplied read-only connector roster; contract v1.2 section 3 lists all 27 unique IDs; individual roster no longer outstanding; new-form configuration and usage verification pending Pending/Unverified Read-only metadata review authorization; no account mutation
@@ -0,0 +1,60 @@
# Phase 9 — Preserve existing referrals link, retire Form C
Date: 2026-11-01
Status: Owner-approved page-routing decision; operational form acceptance and site cutover remain separate
## Decision and live reference
The current public [referrals page](https://www.azinstitute4autism.com/referrals)
has a button-like **Refer a Client** link to
`https://form.jotform.com/231638510219149`.
The owner explicitly requests that the Astro migration preserve that direct
hosted-form destination rather than implement a separate Form C.
This overrides previous Form C referral-initiation planning. Earlier two-form
contracts, form-creation batches and checklists remain historical evidence, not
live instructions to create or launch a form. Physical Form B remains retired.
Form A remains a separate single form at its 16 approved existing placements.
## Code and publication behavior
- `src/content/pages/en/referrals.mdx` retains its button-like CTA and source
content; the CTA now links to the exact existing HTTPS Jotform URL with no
tracking/prefill/query parameters.
- `src/layouts/PageLayout.astro` no longer adds `FormShell` on `/referrals`.
- The unused `referral` native-form mapping and type are removed. The existing
`FormShell` is retained for applicable family consultation pages only.
- No new iframe, Jotform script, webhook, provider integration, form ID, field,
server POST, API origin, measurement, redirect or protected-data collection
is added to the Astro site.
- `/referrals` remains its own published route with existing title, canonical,
body and SEO policy. The Form A allowlist remains unchanged and explicitly
excludes `/referrals`.
## Privacy and operational acceptance
A live-page link is evidence of the intended **URL**, not proof of the linked
form's configuration or compliance. The hosted form presently identifies
itself as a program-consultation form and visibly asks for child and family
details, so the form owner must confirm that this remains an appropriate
professional-referral journey.
Before cutover, the owner should verify the existing Jotform account's access,
PHI handling, privacy settings, recipients and notifications, retention,
attachments, integrations, confirmation behavior, accessibility and
availability. Do not infer a new form's safe defaults from Form A's reviewed
configuration. No account edits or submissions are authorized or performed
by this repository change.
## Regression acceptance
For both default staging and indexing builds, `/referrals` must include one
button-styled `Refer a Client` anchor pointing to the exact external destination,
with no local lead form, `#form-title` fallback or Form A iframe. All other
contact/consultation/landing routes retain their existing behavior.
Updated unit/publication assertions cover the external link, removal of the
inactive referral form, route mapping, and preservation of other form surfaces.
These are source-level assertions until run by the repository owner or CI.
No production deployment, DNS change, Jotform mutation or site cutover is
included in this PR.
@@ -1,4 +1,20 @@
# Phase 9 — Two-form integration specification # Phase 9 — Two-form integration specification (historical title)
## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one
button-styled **Refer a Client** link to
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
to create, embed, configure or launch. The former Form C / proposed two-form
architecture is historical only. `/referrals` renders no local form or Form A
iframe; the original 16 approved Form A placements remain unchanged.
This is authorization to restore the existing destination in Astro, **not**
verification of that separate legacy Jotform's HIPAA configuration, notification
recipients, patient-data handling, accessibility or continued operational
suitability. Review the actual hosted form and its clinical/privacy claims before
production cutover. No account modification or submission occurred in this PR.
## Current decision — 2026-10-30: default-live Form A ## 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 Indexing and forms are independent. Both publication modes emit ten immediate
frames (two contact, five landing, three consultation) and six deferred bilingual frames (two contact, five landing, three consultation) and six deferred bilingual
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS 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, Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
manual CTA override, deferred loading, verified completion acknowledgment for 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 **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 **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: 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. 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: 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/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/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 | | `/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. 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. 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. 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. 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 ## Retained gates
+1 -1
View File
@@ -27,7 +27,7 @@ const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد'
</article> </article>
<style> <style>
.author-byline { color: inherit; text-decoration: none; } .author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
.author-byline:hover { text-decoration: underline; } .author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; } .author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
.blog-card-meta { align-items: center; display: flex; font-size: .78rem; gap: .75rem; } .blog-card-meta { align-items: center; display: flex; font-size: .78rem; gap: .75rem; }
+1 -1
View File
@@ -7,7 +7,7 @@ import { formModeForRuntime, resolveFormAIframe, resolveFormMode, resolveMockFor
const { lang = 'en', showBackground = false, route = '' } = Astro.props; const { lang = 'en', showBackground = false, route = '' } = Astro.props;
const text = formMessages(lang); const text = formMessages(lang);
const title = Astro.props.title ?? text.heading; 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 config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE);
const mode = formModeForRuntime(config); const mode = formModeForRuntime(config);
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT); const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
+7 -5
View File
@@ -1,8 +1,8 @@
--- ---
import { articleMessages } from '../utils/article-messages'; import { articleMessages } from '../utils/article-messages';
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode'; import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
interface Props { slug: string; lang: string } interface Props { slug: string; lang: string; trackView?: boolean }
const { slug, lang } = Astro.props; const { slug, lang, trackView = false } = Astro.props;
const text = articleMessages(lang); const text = articleMessages(lang);
const counterConfig = resolveCounterConfig( const counterConfig = resolveCounterConfig(
import.meta.env.PUBLIC_COUNTERS_MODE, import.meta.env.PUBLIC_COUNTERS_MODE,
@@ -15,21 +15,23 @@ const mockResult = import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'failed' || i
: 'success'; : 'success';
const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`; const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
--- ---
<div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-mock-result={mockResult}> <div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-api-base={counterConfig.apiBase} data-slug={slug} data-track-view={trackView ? 'true' : 'false'} data-like-label={text.like} data-unlike-label={text.unlike} data-ready-message={text.countersReady} data-error-message={text.counterError} data-mock-result={mockResult}>
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span> <span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span>
<span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}> <span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}>
<span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span> <span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span>
<button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode === 'off'}> <button class="like-button" type="button" data-like aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode !== 'mock'}>
<svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon"> <svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon">
<path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path> <path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path>
</svg> </svg>
</button> </button>
</span> </span>
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span> <span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : runtimeMode === 'off' ? text.counterUnavailable : ''}</span>
</div> </div>
<script> <script>
import { bindMockForms } from '../scripts/mock-form'; import { bindMockForms } from '../scripts/mock-form';
import { bindLiveCounters } from '../scripts/likes-views';
bindMockForms(); bindMockForms();
void bindLiveCounters();
</script> </script>
<style> <style>
+57
View File
@@ -0,0 +1,57 @@
---
import { formMessages } from '../utils/form-messages';
const { lang = 'en' } = Astro.props;
const text = formMessages(lang);
const directions = 'https://www.google.com/maps/dir/?api=1&destination=8901%20E%20Raintree%20Dr%20Ste%20160%2C%20Scottsdale%2C%20AZ%2085260';
---
<section class="openstreetmap-section" aria-label={text.map}>
<div id="aia-openstreetmap" class="openstreetmap" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker} data-directions-label={text.directions}></div>
<p data-openstreetmap-fallback>
Arizona Institute for Autism — 8901 E Raintree Dr, Ste 160, Scottsdale, AZ 85260.
<a href={directions} target="_blank" rel="noopener noreferrer">{text.directions}</a>
</p>
</section>
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script is:inline define:vars={{ directions }}>
(() => {
const element = document.getElementById('aia-openstreetmap');
if (!element || !window.L || element.dataset.initialized) return;
const map = window.L.map(element, { zoomControl: false }).setView([33.63, -111.88756], 12);
element.dataset.initialized = 'true';
window.L.control.zoom({ zoomInTitle: element.dataset.zoomIn, zoomOutTitle: element.dataset.zoomOut }).addTo(map);
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
tiles.on('tileload', ({ tile }) => {
tile.alt = element.dataset.tileAlt;
});
tiles.addTo(map);
window.L.marker([33.617746, -111.88756], {
alt: element.dataset.markerLabel,
title: element.dataset.markerLabel
}).addTo(map).bindPopup(`
<span class="openstreetmap__popup">
<b>Arizona Institute for Autism</b><br>
<img alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94"><br>
<a href="${directions}" target="_blank" rel="noopener noreferrer">${element.dataset.directionsLabel}</a>
</span>
`).openPopup();
// Keep address/directions available without JavaScript or if Leaflet is blocked.
document.querySelector('[data-openstreetmap-fallback]')?.setAttribute('hidden', '');
})();
</script>
<style>
.openstreetmap-section { background: white; margin: 0; padding: 0; }
.openstreetmap { height: 360px; width: 100%; }
.openstreetmap :global(.openstreetmap__popup) { display: block; text-align: center; }
.openstreetmap :global(.leaflet-popup-content) { margin: 10px 20px 8px 18px; }
.openstreetmap :global(.openstreetmap__popup img) { display: inline-block; margin-bottom: .25rem; }
.openstreetmap :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 2px; }
[data-openstreetmap-fallback] { margin: 0; padding: 1rem; text-align: center; }
[hidden] { display: none; }
</style>
+2 -52
View File
@@ -2,7 +2,7 @@
import ContactForm from '../ContactForm.astro'; import ContactForm from '../ContactForm.astro';
import FormAPreview from '../FormAPreview.astro'; import FormAPreview from '../FormAPreview.astro';
import JotformIframe from '../JotformIframe.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'; import { formAPreviewEnabled, formModeForRuntime, resolveFormAIframe, resolveFormMode } from '../../utils/form-integration';
interface Props { interface Props {
@@ -11,7 +11,6 @@ interface Props {
} }
const { title, lang } = Astro.props; const { title, lang } = Astro.props;
const text = formMessages(lang);
const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE); 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 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'; const route = lang === 'es' ? '/es/contact' : '/contact';
@@ -35,40 +34,7 @@ const formAIframe = resolveFormAIframe({
</div> </div>
</section> </section>
<section class="contact-map-section" aria-label={text.map}> <OpenStreetMap lang={lang} />
<div id="aia-contact-map" class="contact-map" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker}></div>
</section>
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script is:inline>
(() => {
const mapElement = document.getElementById('aia-contact-map');
if (!mapElement || !window.L) return;
const map = window.L.map(mapElement, { zoomControl: false }).setView([33.63, -111.88756], 12);
window.L.control.zoom({ zoomInTitle: mapElement.dataset.zoomIn, zoomOutTitle: mapElement.dataset.zoomOut }).addTo(map);
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
});
tiles.on('tileload', (event) => {
if (event.tile && !event.tile.alt) {
event.tile.alt = mapElement.dataset.tileAlt;
}
});
tiles.addTo(map);
window.L.marker([33.617746, -111.88756], { alt: mapElement.dataset.markerLabel, title: mapElement.dataset.markerLabel }).addTo(map).bindPopup(`
<span style="display:block;text-align:center">
<b>Arizona Institute for Autism</b><br>
<img class="contact-map__logo" alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94">
</span>
`).openPopup();
})();
</script>
<style> <style>
.contact-page { .contact-page {
@@ -106,22 +72,6 @@ const formAIframe = resolveFormAIframe({
margin-bottom: 40px; 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) { @media (max-width: 992px) {
.contact-page { .contact-page {
padding-block: 50px; padding-block: 50px;
+22 -12
View File
@@ -3,9 +3,11 @@ import { readPortalInput } from '../../utils/portal-input.mjs';
import Button from '../Button.astro'; import Button from '../Button.astro';
// Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build. // Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build.
const payload = readPortalInput({ 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, 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'; const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null';
--- ---
@@ -23,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript> <noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div> </div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div> <div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<p class="employee-portal__download-feedback" role="status" aria-live="polite" hidden data-portal-download-feedback></p>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script> <script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section> </section>
@@ -31,9 +34,11 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
.employee-portal { padding-block: 3rem; overflow-wrap: anywhere; } .employee-portal { padding-block: 3rem; overflow-wrap: anywhere; }
.employee-portal__access { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; } .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 { 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; }
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
[hidden] { display: 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 :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content { line-height: 1.65; } .employee-portal__content { line-height: 1.65; }
.employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) { .employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) {
@@ -63,14 +68,19 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
min-width: 0; min-width: 0;
} }
.employee-portal__content :global(ul.portal-template-grid > li p) { margin: 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; /* Staff resource-button lists do not display bullets or numbers. */
box-sizing: border-box; .employee-portal__content :global(.portal-resource-list) {
display: flex; list-style: none;
height: 100%; padding-inline-start: 0;
min-height: 2.75rem; }
overflow-wrap: anywhere;
width: 100%; .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; } .employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; }
@@ -5,6 +5,7 @@ displayH1: "Early Autism Intervention Makes a Difference"
slug: "lp/early-intervention" slug: "lp/early-intervention"
lang: "en" lang: "en"
canonical: "https://www.azinstitute4autism.com/lp/early-intervention" canonical: "https://www.azinstitute4autism.com/lp/early-intervention"
noindex: true
campaign: "early_intervention" campaign: "early_intervention"
--- ---
+1 -1
View File
@@ -15,7 +15,7 @@ Last updated: February 19, 2025
At Arizona Institute for Autism, we are dedicated to protecting the privacy and security of our website visitors, clients, and their families. This policy explains how we collect, use, and safeguard your personal information. At Arizona Institute for Autism, we are dedicated to protecting the privacy and security of our website visitors, clients, and their families. This policy explains how we collect, use, and safeguard your personal information.
As a healthcare provider, we strictly adhere to the [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index) (Health Insurance Portability and Accountability Act), [COPPA](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-312) (Children's Online Privacy Protection Act), and all applicable federal and state regulations governing the privacy and security of [PHI](https://cphs.berkeley.edu/hipaa/hipaa18) (Protected Health Information). As a healthcare provider, we strictly adhere to the [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index) (Health Insurance Portability and Accountability Act), [COPPA](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-312) (Children's Online Privacy Protection Act), and all applicable federal and state regulations governing the privacy and security of [PHI](https://cphs.berkeley.edu/hipaa/hipaa18.html) (Protected Health Information).
## Information We Collect ## Information We Collect
+1 -1
View File
@@ -26,4 +26,4 @@ At the Arizona Institute for Autism, we are here to support your clients reach n
To provide the highest level of optimal care, we know that it is essential to provide a working partnership with the client’s diagnostic provider, physician, and their insurance company. To facilitate this collaboration, we have built a system of reporting metrics on the child’s treatment plan with supporting progress charts we share with our partners. This information keeps everyone involved up to date on the client’s progress and future needs. If you are an insurance company or diagnostic provider interested in working with us, contact us to learn more about our referral process. To provide the highest level of optimal care, we know that it is essential to provide a working partnership with the client’s diagnostic provider, physician, and their insurance company. To facilitate this collaboration, we have built a system of reporting metrics on the child’s treatment plan with supporting progress charts we share with our partners. This information keeps everyone involved up to date on the client’s progress and future needs. If you are an insurance company or diagnostic provider interested in working with us, contact us to learn more about our referral process.
<Button href="#form-title" center>Refer a Client</Button> <Button href="https://form.jotform.com/231638510219149" center>Refer a Client</Button>
+4 -1
View File
@@ -8,6 +8,7 @@ translationKey: "tour"
draft: false draft: false
--- ---
import ImageCarousel from '../../../components/ImageCarousel.astro'; import ImageCarousel from '../../../components/ImageCarousel.astro';
import Button from '../../../components/Button.astro';
import tourGallery from '../../../data/tour-gallery.json'; import tourGallery from '../../../data/tour-gallery.json';
## Tour Arizona Institute for Autism ## 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 - 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>
+3 -2
View File
@@ -30,10 +30,11 @@ const { entry } = await getPublication(Astro.url.pathname);
:global(.library-intro h2) { color: white; } :global(.library-intro h2) { color: white; }
:global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: minmax(0, 1fr) 240px; } :global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: minmax(0, 1fr) 240px; }
:global(.library-sidebar) { position: sticky; top: 1rem; } :global(.library-sidebar) { position: sticky; top: 1rem; }
:global(.library-sidebar :is(h1, h2, h3, h4, h5, h6)) { font-family: var(--font-body); }
:global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; } :global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; }
:global(.library-sidebar ul) { list-style: none; padding: 0; } :global(.library-sidebar ul) { list-style: none; padding: 0; }
:global(.library-sidebar li) { border-bottom: 1px solid var(--color-border); padding-block: .65rem; } :global(.library-sidebar li) { border-bottom: .15em solid var(--color-accent-strong); padding-block: .65rem; }
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; } :global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; line-height: 1.25; }
:global(.blog-list) { display: block; } :global(.blog-list) { display: block; }
:global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; } :global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; }
:global(.library-search-results h2) { margin-top: 0; } :global(.library-search-results h2) { margin-top: 0; }
+2 -2
View File
@@ -26,13 +26,13 @@ const schema = blogPostingSchema(entry, publication, authors, site.url);
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>} {entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
</header> </header>
<div class="prose blog-post-body container"><slot /></div> <div class="prose blog-post-body container"><slot /></div>
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} /></div> <div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} trackView /></div>
</article> </article>
<BlogPostFooter entryId={entry.id} lang={entry.data.lang} /> <BlogPostFooter entryId={entry.id} lang={entry.data.lang} />
</BaseLayout> </BaseLayout>
<style> <style>
.author-byline { color: inherit; text-decoration: none; } .author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
.author-byline:hover { text-decoration: underline; } .author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; } .author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
.article-header { padding-block: 4.5rem 0; text-align: left; } .article-header { padding-block: 4.5rem 0; text-align: left; }
+6 -3
View File
@@ -7,13 +7,14 @@ import AboutPage from '../components/pages/AboutPage.astro';
import ServicesPage from '../components/pages/ServicesPage.astro'; import ServicesPage from '../components/pages/ServicesPage.astro';
import CareersPage from '../components/pages/CareersPage.astro'; import CareersPage from '../components/pages/CareersPage.astro';
import ContactPage from '../components/pages/ContactPage.astro'; import ContactPage from '../components/pages/ContactPage.astro';
import OpenStreetMap from '../components/OpenStreetMap.astro';
import FaqPage from '../components/pages/FaqPage.astro'; import FaqPage from '../components/pages/FaqPage.astro';
import InsurancePage from '../components/pages/InsurancePage.astro'; import InsurancePage from '../components/pages/InsurancePage.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../components/PageHero.astro';
import ServiceInquiryDrawer from '../components/ServiceInquiryDrawer.astro'; import ServiceInquiryDrawer from '../components/ServiceInquiryDrawer.astro';
import { pageHeroImages } from '../data/page-visuals'; import { pageHeroImages } from '../data/page-visuals';
const { entry } = Astro.props; 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>> = { const serviceTitles: Record<string, Record<string, string>> = {
en: { en: {
'aba-therapy': 'Behavioral', '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 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 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 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]; const heroImage = pageHeroImages[entry.data.slug];
if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team) { if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team) {
throw new Error('The English Team page requires structured team frontmatter.'); 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}> <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 /> <PageHero title={entry.data.displayH1 ?? bannerTitle} subtitle={entry.data.slug === 'faqs' ? 'FAQs' : undefined} image={heroImage} constrain />
{entry.data.slug === 'team' && entry.data.lang === 'en' {entry.data.slug === 'team' && entry.data.lang === 'en'
? <TeamPage content={entry.data.team!} /> ? <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' : entry.data.slug === 'contact'
? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage> ? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage>
: <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>} : <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} />} {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> </BaseLayout>
+67 -2
View File
@@ -1,4 +1,5 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs'; import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
import { portalLinkPresentation } from '../utils/portal-presentation'; import { portalLinkPresentation } from '../utils/portal-presentation';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media, /** Build a passive DOM, never inject executable decrypted HTML. Remote media,
@@ -24,6 +25,8 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation; if (presentation) element.className = presentation;
const resource = protectedPortalResource(url.href, location.origin, source.textContent ?? '');
if (resource) element.dataset.portalResourceRole = resource.role;
} }
} catch { /* Invalid destination remains visible text without navigation. */ } } catch { /* Invalid destination remains visible text without navigation. */ }
} }
@@ -76,6 +79,19 @@ function enhanceStaffPortalPresentation(fragment: DocumentFragment): void {
} }
if (!templatesList) templatesList = templatesHeading.closest('section, article')?.querySelector('ul') ?? null; if (!templatesList) templatesList = templatesHeading.closest('section, article')?.querySelector('ul') ?? null;
templatesList?.classList.add('portal-template-grid'); 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() { function initialize() {
const root = document.querySelector<HTMLElement>('[data-employee-portal]'); const root = document.querySelector<HTMLElement>('[data-employee-portal]');
@@ -86,6 +102,9 @@ function initialize() {
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!; const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!; const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!; const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
const downloadFeedback = root.querySelector<HTMLElement>('[data-portal-download-feedback]')!;
let credential: { role: 'staff' | 'management'; code: string } | null = null;
let downloading = false;
let data: ReturnType<typeof validatePortalPayload>; let data: ReturnType<typeof validatePortalPayload>;
try { try {
if (!crypto.subtle) throw new Error(); if (!crypto.subtle) throw new Error();
@@ -93,6 +112,47 @@ function initialize() {
input.disabled = false; button.disabled = false; input.disabled = false; button.disabled = false;
feedback.textContent = ''; feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; } } catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const clearSession = () => {
credential = null;
input.value = '';
content.replaceChildren();
content.hidden = true;
prompt.hidden = false;
downloadFeedback.hidden = true;
downloadFeedback.textContent = '';
};
const saveDownload = (blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove();
// Both supported browsers have ample time to hand the Blob to their
// download manager before its temporary URL is released.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
};
content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return;
event.preventDefault();
if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin, target.textContent ?? '');
if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false;
return;
}
downloading = true;
target.setAttribute('aria-disabled', 'true');
downloadFeedback.textContent = 'Downloading document…';
downloadFeedback.hidden = false;
void fetchProtectedPortalResource(resource, credential)
.then(({ blob, filename }) => {
saveDownload(blob, filename);
downloadFeedback.textContent = `Downloaded ${filename}.`;
})
.catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
.finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
});
const unlock = async () => { const unlock = async () => {
if (button.disabled) return; if (button.disabled) return;
button.disabled = true; button.disabled = true;
@@ -103,8 +163,12 @@ function initialize() {
for (const stream of data.streams) { for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass); const plain = await decryptPortalStream(stream, pass);
if (plain !== null) { if (plain !== null) {
const role = portalRoleForStreamId(stream.id);
if (!role) throw new Error('Unknown portal stream');
credential = { role, code: pass };
content.replaceChildren(safePortalContent(plain)); content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false; prompt.hidden = true; content.hidden = false;
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
input.value = ''; content.focus(); return; input.value = ''; content.focus(); return;
} }
} }
@@ -114,8 +178,9 @@ function initialize() {
}; };
button.addEventListener('click', unlock); button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } }); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
// BFCache must not restore an already unlocked view. // Credentials exist only in this page closure and are discarded on exit/swap.
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; }); addEventListener('pagehide', clearSession, { once: true });
document.addEventListener('astro:before-swap', clearSession, { once: true });
} }
initialize(); initialize();
document.addEventListener('astro:page-load', initialize); document.addEventListener('astro:page-load', initialize);
+248 -2
View File
@@ -1,2 +1,248 @@
/** Live counter behavior is intentionally not enabled by this migration. */ const likeStorageKey = 'aia-liked-articles-v1';
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode'; const viewCooldownStorageKey = 'aia-view-cooldowns-v1';
export const articleViewCooldownMs = 30 * 60 * 1_000;
const requestTimeoutMs = 8_000;
const pendingLikes = new Set<string>();
type CounterStats = Record<string, { likes: number; views: number }>;
type Fetcher = typeof fetch;
function endpoint(base: string, path: string) {
return `${base.replace(/\/$/, '')}${path}`;
}
function browserStorage(): Pick<Storage, 'getItem' | 'setItem'> {
try {
return localStorage;
} catch {
return { getItem: () => null, setItem: () => undefined };
}
}
function browserSessionStorage(): Pick<Storage, 'getItem' | 'setItem'> {
try {
return sessionStorage;
} catch {
return { getItem: () => null, setItem: () => undefined };
}
}
export function reserveArticleView(
slug: string,
storage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
now = Date.now(),
) {
let active: Record<string, number> = {};
try {
const parsed = JSON.parse(storage.getItem(viewCooldownStorageKey) ?? '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
active = Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, number] => {
const timestamp = entry[1];
return typeof timestamp === 'number'
&& Number.isFinite(timestamp)
&& now >= timestamp
&& now - timestamp < articleViewCooldownMs;
}));
}
} catch {
// Corrupt or blocked session storage must not prevent article rendering.
}
if (active[slug] !== undefined) return false;
active[slug] = now;
try {
storage.setItem(viewCooldownStorageKey, JSON.stringify(active));
} catch {
// The request may proceed when storage is unavailable.
}
return true;
}
export function readLikedSlugs(storage: Pick<Storage, 'getItem'> = browserStorage()) {
try {
const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]');
return new Set(Array.isArray(parsed) ? parsed.filter((slug): slug is string => typeof slug === 'string') : []);
} catch {
return new Set<string>();
}
}
function writeLikedSlugs(liked: Set<string>, storage: Pick<Storage, 'setItem'>) {
try {
storage.setItem(likeStorageKey, JSON.stringify([...liked].sort()));
} catch {
// The counter remains usable in memory when storage is unavailable.
}
}
async function requestJson(fetcher: Fetcher, url: string, init: RequestInit, timeoutMs = requestTimeoutMs) {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetcher(url, {
...init,
signal: controller.signal,
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
if (!response.ok) throw new Error(`Counter API returned ${response.status}`);
return await response.json();
} finally {
globalThis.clearTimeout(timeout);
}
}
export async function fetchCounterStats(apiBase: string, slugs: string[], fetcher: Fetcher = fetch, timeoutMs = requestTimeoutMs): Promise<CounterStats> {
return requestJson(fetcher, endpoint(apiBase, '/stats/batch'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...new Set(slugs)] }),
}, timeoutMs);
}
export async function reportArticleView(apiBase: string, slug: string, fetcher: Fetcher = fetch): Promise<number> {
const result = await requestJson(fetcher, endpoint(apiBase, '/views'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug }),
keepalive: true,
});
if (!Number.isInteger(result.views) || result.views < 0) throw new Error('Invalid view response');
return result.views;
}
export async function updateLike(apiBase: string, slug: string, liked: boolean, fetcher: Fetcher = fetch): Promise<number> {
const result = await requestJson(fetcher, endpoint(apiBase, liked ? `/likes/${encodeURIComponent(slug)}` : '/likes'), {
method: liked ? 'DELETE' : 'POST',
headers: liked ? undefined : { 'Content-Type': 'application/json' },
body: liked ? undefined : JSON.stringify({ slug }),
});
if (!Number.isInteger(result.count) || result.count < 0) throw new Error('Invalid like response');
return result.count;
}
function countersForSlug(root: ParentNode, slug: string) {
return [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')]
.filter((counter) => counter.dataset.slug === slug);
}
function render(root: ParentNode, slug: string, stats: { likes?: number; views?: number }) {
for (const counter of countersForSlug(root, slug)) {
if (stats.views !== undefined) {
const views = counter.querySelector<HTMLElement>('[data-views]');
if (views) views.textContent = String(stats.views);
}
if (stats.likes !== undefined) {
const likes = counter.querySelector<HTMLElement>('[data-likes]');
if (likes) likes.textContent = String(stats.likes);
}
}
}
function renderLiked(root: ParentNode, slug: string, liked: boolean) {
for (const counter of countersForSlug(root, slug)) {
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
if (!button) continue;
button.setAttribute('aria-pressed', String(liked));
button.classList.toggle('liked', liked);
button.setAttribute('aria-label', liked ? counter.dataset.unlikeLabel ?? 'Remove your like' : counter.dataset.likeLabel ?? 'Like this article');
}
}
function status(root: ParentNode, slug: string, failed: boolean) {
for (const counter of countersForSlug(root, slug)) {
const output = counter.querySelector<HTMLElement>('.counter-status');
if (output) output.textContent = failed ? counter.dataset.errorMessage ?? 'Engagement metrics are temporarily unavailable' : counter.dataset.readyMessage ?? '';
}
}
export async function bindLiveCounters(
root: ParentNode = document,
fetcher: Fetcher = fetch,
storage: Pick<Storage, 'getItem' | 'setItem'> = browserStorage(),
viewStorage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
) {
const counters = [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')];
const pending = counters.filter((counter) => counter.dataset.counterBound !== 'true');
if (!pending.length) return;
for (const counter of pending) counter.dataset.counterBound = 'true';
const liked = readLikedSlugs(storage);
const apiBase = pending[0].dataset.apiBase;
const slugs = [...new Set(pending.map((counter) => counter.dataset.slug).filter((slug): slug is string => Boolean(slug)))];
if (!apiBase || !slugs.length) return;
for (const slug of slugs) renderLiked(root, slug, liked.has(slug));
const viewRequests = pending
.filter((counter) => counter.dataset.trackView === 'true' && counter.dataset.viewReported !== 'true')
.map((counter) => {
const slug = counter.dataset.slug;
if (!slug) return undefined;
// Mark before the non-retried request. Repeated initialization and
// bfcache restoration reuse this DOM marker and cannot double-count.
counter.dataset.viewReported = 'true';
// A short per-tab reservation also suppresses a newly rendered copy of
// the same article after back navigation, without a cookie or visitor ID.
if (!reserveArticleView(slug, viewStorage)) return undefined;
return reportArticleView(apiBase, slug, fetcher)
.then((views) => ({ slug, views, failed: false }))
.catch(() => ({ slug, views: undefined, failed: true }));
})
.filter((request): request is NonNullable<typeof request> => Boolean(request));
// Start the pageview request immediately. Statistics retrieval and rendering
// remain nonblocking, and the later view response wins any read/increment race.
try {
const stats = await fetchCounterStats(apiBase, slugs, fetcher);
for (const slug of slugs) {
if (stats[slug]) render(root, slug, stats[slug]);
status(root, slug, false);
}
} catch {
for (const slug of slugs) status(root, slug, true);
} finally {
for (const counter of pending) {
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
const slug = counter.dataset.slug;
if (!button || !slug) continue;
button.disabled = false;
button.addEventListener('click', async () => {
if (pendingLikes.has(slug)) return;
pendingLikes.add(slug);
for (const copy of countersForSlug(root, slug)) {
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
if (copyButton) copyButton.disabled = true;
}
const wasLiked = liked.has(slug);
try {
const count = await updateLike(apiBase, slug, wasLiked, fetcher);
wasLiked ? liked.delete(slug) : liked.add(slug);
writeLikedSlugs(liked, storage);
render(root, slug, { likes: count });
renderLiked(root, slug, !wasLiked);
status(root, slug, false);
} catch {
status(root, slug, true);
} finally {
pendingLikes.delete(slug);
for (const copy of countersForSlug(root, slug)) {
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
if (copyButton) copyButton.disabled = false;
}
}
});
}
}
for (const request of viewRequests) {
const result = await request;
if (result.failed || result.views === undefined) status(root, result.slug, true);
else render(root, result.slug, { views: result.views });
}
}
if (typeof document !== 'undefined') {
document.addEventListener('astro:page-load', () => void bindLiveCounters());
}
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode.ts';
+2 -2
View File
@@ -2,8 +2,8 @@
.prose h2, .prose h3 { margin-top: 1.75em; } .prose h2, .prose h3 { margin-top: 1.75em; }
.prose img { border-radius: var(--radius-md); margin-block: var(--space-lg); } .prose img { border-radius: var(--radius-md); margin-block: var(--space-lg); }
.prose a { overflow-wrap: anywhere; text-decoration: none; } .prose a { overflow-wrap: anywhere; text-decoration: none; }
.prose :is(p, li, td) a:not(.content-button) { color: var(--color-primary); text-decoration: underline; text-underline-offset: .15em; } .prose :is(p, li, td) a:not(.content-button) { color: var(--color-accent-strong); text-decoration: underline; text-underline-offset: .15em; }
.prose :is(p, li, td) a:not(.content-button):is(:hover, :focus-visible) { color: var(--color-primary-dark); } .prose :is(p, li, td) a:not(.content-button):is(:hover, :focus-visible) { color: var(--color-accent); }
.blog-post-body blockquote { background: var(--color-tint); border: 0; border-radius: 16px; color: var(--color-primary); margin: 0 auto 2.5rem; max-width: 767px; padding: 45px; position: relative; z-index: 0; } .blog-post-body blockquote { background: var(--color-tint); border: 0; border-radius: 16px; color: var(--color-primary); margin: 0 auto 2.5rem; max-width: 767px; padding: 45px; position: relative; z-index: 0; }
.blog-post-body blockquote::before { background-image: url('/assets/images/blockquote.svg'); content: ""; display: block; height: 41px; inset-inline-start: 6px; position: absolute; top: 22px; width: 50px; z-index: -1; } .blog-post-body blockquote::before { background-image: url('/assets/images/blockquote.svg'); content: ""; display: block; height: 41px; inset-inline-start: 6px; position: absolute; top: 22px; width: 50px; z-index: -1; }
.blog-post-body blockquote p { color: var(--color-primary); margin: 0 0 1.55rem; } .blog-post-body blockquote p { color: var(--color-primary); margin: 0 0 1.55rem; }
+8 -1
View File
@@ -17,7 +17,7 @@ a:hover, a:active { color: var(--color-accent); }
.eyebrow { .eyebrow {
margin: 0; margin: 0;
color: var(--color-primary); color: var(--color-accent-strong);
font-family: var(--font-accent); font-family: var(--font-accent);
font-size: 2rem; font-size: 2rem;
} }
@@ -26,6 +26,13 @@ a:hover, a:active { color: var(--color-accent); }
margin-block-start: 0; margin-block-start: 0;
} }
.blog-card .text-link,
.blog-card .text-link:visited {
text-decoration-color: var(--color-accent-strong);
text-decoration-thickness: .15em;
text-underline-offset: .5em;
}
.prose :where(ul, ol) { .prose :where(ul, ol) {
margin-block: 0 1.5rem; margin-block: 0 1.5rem;
padding-inline-start: 2.5rem; padding-inline-start: 2.5rem;
+2
View File
@@ -1,12 +1,14 @@
/** Arabic article chrome; preserve the existing English/Spanish labels. */ /** Arabic article chrome; preserve the existing English/Spanish labels. */
const english = { const english = {
views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article', views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article',
unlike: 'Remove your like', countersReady: '', counterError: 'Engagement metrics are temporarily unavailable',
counterUnavailable: 'Engagement metrics are unavailable', counterUnavailable: 'Engagement metrics are unavailable',
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post', navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
related: 'Similar Blog Posts', readFull: 'Read full post', related: 'Similar Blog Posts', readFull: 'Read full post',
}; };
const arabic: typeof english = { const arabic: typeof english = {
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال', views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
unlike: 'إزالة الإعجاب', countersReady: '', counterError: 'إحصاءات التفاعل غير متاحة مؤقتًا',
counterUnavailable: 'إحصاءات التفاعل غير متاحة', counterUnavailable: 'إحصاءات التفاعل غير متاحة',
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي', navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً', related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
+2 -4
View File
@@ -30,8 +30,6 @@ export function resolveCounterConfig(
return { mode: 'live', apiBase, reason: 'live-approved' }; return { mode: 'live', apiBase, reason: 'live-approved' };
} }
export function counterModeForRuntime(config: CounterConfig): 'off' | 'mock' { export function counterModeForRuntime(config: CounterConfig): CounterMode {
// Live behavior is intentionally not implemented by the migration. Keep an return config.mode;
// approved future setting fail-closed until a separately authorized adapter exists.
return config.mode === 'mock' ? 'mock' : 'off';
} }
+1 -2
View File
@@ -1,5 +1,5 @@
export type FormMode = 'live' | 'off' | 'mock'; export type FormMode = 'live' | 'off' | 'mock';
export type LogicalForm = 'general' | 'service' | 'referral'; export type LogicalForm = 'general' | 'service';
export type MockFormResult = 'success' | 'failed' | 'unavailable'; export type MockFormResult = 'success' | 'failed' | 'unavailable';
export interface FormModeConfig { export interface FormModeConfig {
@@ -170,7 +170,6 @@ export const formRouteMapping: ReadonlyArray<{
{ route: '/client-consultation', logicalForm: 'service', kind: 'native-instance' }, { route: '/client-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/es/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: '/schedule-consultation', logicalForm: 'service', kind: 'native-instance' },
{ route: '/referrals', logicalForm: 'referral', kind: 'native-instance' },
{ route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' }, { route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' },
{ route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' }, { route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' },
{ route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' }, { route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' },
+2 -2
View File
@@ -10,7 +10,7 @@ const messages = {
mock: 'Demo only: this local fixture never sends information.', mockSuccess: 'Demo only: the local fixture completed without sending anything.', mockFailed: 'Demo only: the local fixture simulated a failed submission.', mockUnavailable: 'Demo only: the local fixture simulated an unavailable service.', 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', map: 'Map showing Arizona Institute for Autism in Scottsdale',
tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ', 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: { formAPreview: {
internalPreview: 'Internal meeting preview', heading: 'General enquiries preview', internalPreview: 'Internal meeting preview', heading: 'General enquiries preview',
name: 'Your name', phone: 'Phone number', email: 'Email address', 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.', 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', 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', 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: { formAPreview: {
internalPreview: 'Vista previa para reunión interna', heading: 'Vista previa de consultas generales', 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', name: 'Su nombre', phone: 'Número de teléfono', email: 'Correo electrónico',
+11 -1
View File
@@ -6,7 +6,17 @@ export function isFormACompletion(event: { origin: string; source: unknown; data
// never from the message, current child location, query string or referrer. // never from the message, current child location, query string or referrer.
let expected: URL; let expected: URL;
try { expected = new URL(approvedUrl); } catch { return false; } 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; let data = event.data;
if (typeof data === 'string') { if (typeof data === 'string') {
if (data.length > 4096) return false; if (data.length > 4096) return false;
+74
View File
@@ -0,0 +1,74 @@
const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
const serverExtension = /([.][a-z0-9]{1,10})$/i;
export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff';
if (id === 'a') return 'management';
return undefined;
}
export function recognizableDownloadFilename(label, serverFilename) {
const extension = serverExtension.exec(serverFilename)?.[1]?.toLowerCase();
if (!extension) return undefined;
let title = typeof label === 'string' ? label.trim() : '';
if (title.toLowerCase().endsWith(extension)) title = title.slice(0, -extension.length);
const cleaned = title
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(/[’']/g, '')
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
const base = [...cleaned].slice(0, 180).join('').replace(/-+$/g, '');
if (!base || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(base)) return `AIA-document${extension}`;
return `${base}${extension}`;
}
export function protectedPortalResource(href, origin, label = '') {
let url;
try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname);
if (!match) return undefined;
const downloadFilename = recognizableDownloadFilename(label, match[2]);
if (!downloadFilename) return undefined;
return { role: match[1], filename: match[2], downloadFilename, url: url.href };
}
function basicAuthorization(role, code) {
if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
throw new PortalDownloadError('session');
}
const bytes = new TextEncoder().encode(`${role}:${code}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
}
export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
const response = await fetcher(resource.url, {
method: 'GET',
headers: { Authorization: basicAuthorization(credential.role, credential.code) },
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
});
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: resource.downloadFilename };
}
export function portalDownloadErrorMessage(error) {
if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
}
return 'The document could not be downloaded. Please try again or contact AIA.';
}
+14 -4
View File
@@ -1,10 +1,20 @@
// Build-only reader; never imported by a browser script. // Build-only reader; never imported by a browser script.
import fs from 'node:fs'; import fs from 'node:fs';
import { validatePortalPayload } from './portal-crypto.mjs'; import { validatePortalPayload } from './portal-crypto.mjs';
export function readPortalInput({ approved, indexing, file }) { /** Build-only reader. Local preview and approved public publication are distinct. */
if (approved !== 'true' || indexing !== 'false' || !file) return undefined; 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 { 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'))); return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8')));
} catch { return undefined; } } catch { throw new Error('Approved portal ciphertext is missing or malformed'); }
} }
+132
View File
@@ -0,0 +1,132 @@
/**
* Browser regression for the built live-counter artifact. Every API request is
* intercepted and fulfilled locally; this script never writes production data.
*/
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const browserName = process.argv.find((argument) => argument.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
: 'playwright';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
await new Promise((resolve) => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const apiOrigin = 'https://api.azinstitute4autism.com';
const calls = [];
let views = 100;
let likes = 5;
let browser;
let preview;
try {
const drivers = await import(moduleName);
const driver = drivers[browserName];
const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath ? { headless: true, executablePath } : { headless: true });
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
cwd: root,
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
stdio: 'ignore',
});
const reachable = () => new Promise((resolve) => {
const request = http.get(`${origin}/`, (response) => { response.resume(); resolve(response.statusCode === 200); });
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
for (let attempt = 0; attempt < 60 && !(await reachable()); attempt += 1) await delay(250);
assert.equal(await reachable(), true, 'Loopback Astro preview did not start');
const context = await browser.newContext();
await context.route(`${apiOrigin}/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, DELETE, OPTIONS',
'access-control-allow-headers': 'content-type',
} });
return;
}
const body = request.postDataJSON?.() ?? undefined;
calls.push({ method: request.method(), path: url.pathname, body });
let response;
if (url.pathname === '/stats/batch') {
response = Object.fromEntries(body.slugs.map((slug) => [slug, { likes, views }]));
} else if (url.pathname === '/views') {
views += 1;
response = { views };
} else if (url.pathname === '/likes' && request.method() === 'POST') {
likes += 1;
response = { count: likes };
} else if (url.pathname.startsWith('/likes/') && request.method() === 'DELETE') {
likes = Math.max(0, likes - 1);
response = { count: likes };
} else {
await route.fulfill({ status: 404, body: '{}' });
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': origin },
body: JSON.stringify(response),
});
});
const page = await context.newPage();
const article = '/library/aba-school-readiness-guide';
await page.goto(`${origin}${article}`);
await page.locator('[data-counter][data-track-view="true"] [data-views]').waitFor();
await assert.doesNotReject(async () => page.waitForFunction(() =>
document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '101'));
assert.equal(calls.filter((call) => call.path === '/views').length, 1);
assert.deepEqual(calls.find((call) => call.path === '/views').body, { slug: 'aba-school-readiness-guide' });
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'true');
assert.equal(calls.filter((call) => call.path === '/likes' && call.method === 'POST').length, 1);
assert.equal(await page.evaluate(() => localStorage.length), 1);
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), ['aia-liked-articles-v1']);
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'false');
assert.equal(calls.filter((call) => call.method === 'DELETE').length, 1);
await page.reload();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '102');
assert.equal(calls.filter((call) => call.path === '/views').length, 2, 'a real reload is a new view');
const beforeListing = calls.filter((call) => call.path === '/views').length;
await page.goto(`${origin}/library`);
await page.waitForFunction(() => document.querySelector('[data-counter] [data-views]')?.textContent !== '—');
assert.equal(calls.filter((call) => call.path === '/views').length, beforeListing, 'listing cards never report views');
for (const route of [
'/es/library/executive-functioning-skills-autism',
'/ar/library/executive-functioning-skills-autism',
]) {
await page.goto(`${origin}${route}`);
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"]')?.dataset.viewReported === 'true');
}
const translated = calls.filter((call) => call.path === '/views').slice(-2);
assert.deepEqual(translated.map((call) => call.body.slug), [
'executive-functioning-skills-autism',
'executive-functioning-skills-autism',
]);
console.log(JSON.stringify({ browser: browserName, version: browser.version(), checks: 18, productionRequests: 0 }));
await context.close();
} finally {
if (preview) preview.kill('SIGTERM');
if (browser) await browser.close();
}
+246
View File
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import fg from 'fast-glob';
import matter from 'gray-matter';
import {
articleViewCooldownMs,
bindLiveCounters,
fetchCounterStats,
readLikedSlugs,
reportArticleView,
reserveArticleView,
updateLike,
} from '../src/scripts/likes-views.ts';
class FakeClassList {
values = new Set();
toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); }
}
class FakeButton {
attributes = new Map([['aria-pressed', 'false']]);
classList = new FakeClassList();
disabled = true;
listeners = [];
setAttribute(name, value) { this.attributes.set(name, value); }
getAttribute(name) { return this.attributes.get(name); }
addEventListener(name, callback) { if (name === 'click') this.listeners.push(callback); }
async click() { for (const callback of this.listeners) await callback(); }
}
class FakeCounter {
constructor(slug, { trackView = false, lang = 'en' } = {}) {
this.dataset = {
slug,
counterMode: 'live',
apiBase: 'https://api.example.test',
trackView: String(trackView),
likeLabel: lang === 'ar' ? 'إعجاب' : 'Like',
unlikeLabel: lang === 'ar' ? 'إزالة الإعجاب' : 'Unlike',
readyMessage: '',
errorMessage: lang === 'ar' ? 'خطأ مؤقت' : 'Temporarily unavailable',
};
this.views = { textContent: '—' };
this.likes = { textContent: '—' };
this.status = { textContent: '' };
this.button = new FakeButton();
}
querySelector(selector) {
return selector === '[data-views]' ? this.views
: selector === '[data-likes]' ? this.likes
: selector === '[data-like]' ? this.button
: selector === '.counter-status' ? this.status : null;
}
}
class FakeRoot {
constructor(counters) { this.counters = counters; }
querySelectorAll(selector) {
return selector === '[data-counter][data-counter-mode="live"]' ? this.counters : [];
}
}
class FakeStorage {
values = new Map();
getItem(key) { return this.values.get(key) ?? null; }
setItem(key, value) { this.values.set(key, value); }
}
function response(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
test('counter transport batches unique slugs and never sends credentials', async () => {
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
return response({ article: { likes: 3, views: 9 } });
};
const result = await fetchCounterStats('https://api.example.test/', ['article', 'article'], fetcher);
assert.equal(result.article.views, 9);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://api.example.test/stats/batch');
assert.equal(calls[0].options.credentials, 'omit');
assert.equal(calls[0].options.referrerPolicy, 'no-referrer');
assert.deepEqual(JSON.parse(calls[0].options.body), { slugs: ['article'] });
});
test('article views and likes use the existing API contracts', async () => {
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
return response(url.endsWith('/views') ? { views: 42 } : { count: 8 });
};
assert.equal(await reportArticleView('https://api.example.test', 'article', fetcher), 42);
assert.equal(await updateLike('https://api.example.test', 'article', false, fetcher), 8);
assert.equal(await updateLike('https://api.example.test', 'article', true, fetcher), 8);
assert.deepEqual(JSON.parse(calls[0].options.body), { slug: 'article' });
assert.equal(calls[0].options.keepalive, true);
assert.equal(calls[1].options.method, 'POST');
assert.equal(calls[2].options.method, 'DELETE');
assert.equal(calls[2].url, 'https://api.example.test/likes/article');
});
test('one article navigation increments once while cards only retrieve statistics', async () => {
const article = new FakeCounter('shared-slug', { trackView: true });
const duplicate = new FakeCounter('shared-slug');
const card = new FakeCounter('other-slug');
const root = new FakeRoot([article, duplicate, card]);
const storage = new FakeStorage();
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
if (url.endsWith('/stats/batch')) return response({
'shared-slug': { likes: 4, views: 20 },
'other-slug': { likes: 1, views: 2 },
});
if (url.endsWith('/views')) return response({ views: 21 });
if (options.method === 'POST') return response({ count: 5 });
return response({ count: 4 });
};
await bindLiveCounters(root, fetcher, storage);
await bindLiveCounters(root, fetcher, storage);
assert.equal(calls.filter((call) => call.url.endsWith('/stats/batch')).length, 1);
assert.equal(calls.filter((call) => call.url.endsWith('/views')).length, 1);
assert.equal(article.views.textContent, '21');
assert.equal(duplicate.views.textContent, '21');
assert.equal(card.views.textContent, '2');
await article.button.click();
assert.equal(article.likes.textContent, '5');
assert.equal(duplicate.likes.textContent, '5');
assert.equal(article.button.getAttribute('aria-pressed'), 'true');
assert.deepEqual([...readLikedSlugs(storage)], ['shared-slug']);
await duplicate.button.click();
assert.equal(article.likes.textContent, '4');
assert.equal(duplicate.button.getAttribute('aria-pressed'), 'false');
assert.deepEqual([...readLikedSlugs(storage)], []);
});
test('a short per-tab cooldown suppresses back-navigation duplicates and then expires', async () => {
const likeStorage = new FakeStorage();
const viewStorage = new FakeStorage();
const calls = [];
const fetcher = async (url) => {
calls.push(url);
return response(url.endsWith('/views')
? { views: 21 }
: { 'shared-slug': { likes: 4, views: 20 } });
};
await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage);
await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage);
assert.equal(calls.filter((url) => url.endsWith('/views')).length, 1);
const clockStorage = new FakeStorage();
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000), true);
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs - 1), false);
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs), true);
assert.equal(reserveArticleView('different-slug', clockStorage, 10_000 + articleViewCooldownMs), true);
});
test('invalid or unavailable session storage fails open without persisting an identifier', () => {
const corrupt = new FakeStorage();
corrupt.setItem('aia-view-cooldowns-v1', '{broken');
assert.equal(reserveArticleView('article', corrupt, 10_000), true);
assert.deepEqual(JSON.parse(corrupt.getItem('aia-view-cooldowns-v1')), { article: 10_000 });
const blocked = {
getItem() { throw new Error('blocked'); },
setItem() { throw new Error('blocked'); },
};
assert.equal(reserveArticleView('article', blocked, 10_000), true);
});
test('API failures preserve rendering and expose localized inline status', async () => {
const counter = new FakeCounter('article', { trackView: true, lang: 'ar' });
const root = new FakeRoot([counter]);
await bindLiveCounters(root, async () => response({ error: 'offline' }, 503), new FakeStorage());
assert.equal(counter.status.textContent, 'خطأ مؤقت');
assert.equal(counter.button.disabled, false);
assert.equal(counter.dataset.viewReported, 'true');
});
test('like persistence rejects corrupt and non-array storage values', () => {
const storage = new FakeStorage();
storage.setItem('aia-liked-articles-v1', '{broken');
assert.deepEqual([...readLikedSlugs(storage)], []);
storage.setItem('aia-liked-articles-v1', JSON.stringify(['valid', 4, null]));
assert.deepEqual([...readLikedSlugs(storage)], ['valid']);
});
test('blocked local storage does not duplicate or disable a successful like', async () => {
const counter = new FakeCounter('article');
const root = new FakeRoot([counter]);
const storage = {
getItem() { throw new Error('blocked'); },
setItem() { throw new Error('blocked'); },
};
let writes = 0;
const fetcher = async (url) => {
if (url.endsWith('/stats/batch')) return response({ article: { likes: 2, views: 3 } });
writes += 1;
return response({ count: 3 });
};
await bindLiveCounters(root, fetcher, storage);
await counter.button.click();
assert.equal(writes, 1);
assert.equal(counter.likes.textContent, '3');
assert.equal(counter.button.getAttribute('aria-pressed'), 'true');
assert.equal(counter.button.disabled, false);
});
test('counter requests time out without retrying an ambiguous operation', async () => {
let calls = 0;
const hanging = (_url, options) => {
calls += 1;
return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason)));
};
await assert.rejects(fetchCounterStats('https://api.example.test', ['article'], hanging, 5));
assert.equal(calls, 1);
});
test('only article layouts track views and translated publications retain exact legacy slugs', async () => {
const layout = await fs.readFile('src/layouts/BlogPostLayout.astro', 'utf8');
const card = await fs.readFile('src/components/BlogCard.astro', 'utf8');
assert.match(layout, /LikeViewCounter[^>]*trackView/);
assert.doesNotMatch(card, /trackView/);
const groups = new Map();
for (const file of await fg('src/content/blog/**/*.{md,mdx}')) {
const frontmatter = matter(await fs.readFile(file, 'utf8')).data;
if (!frontmatter.translationKey || frontmatter.draft) continue;
const entries = groups.get(frontmatter.translationKey) ?? [];
entries.push({ lang: frontmatter.lang, slug: frontmatter.slug, file: path.basename(file) });
groups.set(frontmatter.translationKey, entries);
}
const multilingual = [...groups.values()].filter((entries) => entries.length > 1);
assert.ok(multilingual.some((entries) => new Set(entries.map(({ lang }) => lang)).size === 3));
for (const entries of multilingual) {
assert.equal(new Set(entries.map(({ slug }) => slug)).size, 1, JSON.stringify(entries));
}
});
+117
View File
@@ -0,0 +1,117 @@
/** 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';
import { mappingsFor, approvedMapping } from './portal-production.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 runBytes(command, args) {
const result = spawnSync(command, args, { cwd: toolRoot });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr?.length ? `: ${result.stderr.toString().trim()}` : ''}`);
return result.stdout;
}
function safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
function roleAndName(record, mapping) {
const role = mapping?.role;
if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
const prefix = `/${role}-resources/`;
const name = mapping.deliveryPath.slice(prefix.length);
if (!safeName(name) || mapping.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) {
for (const mapping of mappingsFor(record)) {
if (!['staff', 'management'].includes(mapping.role)) continue;
const { role, name } = roleAndName(record, mapping);
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 reviewedInstaller = runBytes('git', ['-C', repositoryRoot, 'show', `${sha}:www/tools/portal-resource-install.sh`]);
const localInstaller = await fs.readFile(path.join(toolRoot, 'tools', 'portal-resource-install.sh'));
if (!reviewedInstaller.equals(localInstaller)) fail('Local resource installer does not match the reviewed source commit');
const installerHash = hash(reviewedInstaller);
const installerFile = path.join(temporary, 'portal-resource-install.sh');
await fs.writeFile(installerFile, reviewedInstaller, { mode: 0o600 });
const remoteInstaller = `${remoteStage}/portal-resource-install.sh`;
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('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, installerFile, `${host}:${remoteInstaller}`]);
run(ssh, [...sshOptions, host, `test "$(sha256sum ${remoteInstaller} | awk '{print $1}')" = ${installerHash}`]);
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${remoteInstaller} ${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,92 @@
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', mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, destinationInstalled: false, destinationHashVerified: false, destinationPermissionsVerified: false, destinationAccessVerified: false, 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', mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length }] }));
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /approved for installation/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('one verified source may be installed into both protected role namespaces', async () => {
const { root, privateRoot, hubfs, stage } = await fixture();
try {
const localFile = 'b'.repeat(64), bytes = Buffer.from('synthetic shared non-phi document');
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
const mapping = (role) => ({ role, classification: role === 'staff' ? 'STAFF-INTERNAL' : 'MANAGEMENT-INTERNAL', deliveryPath: `/${role}-resources/shared.pdf`, mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, destinationInstalled: false, destinationHashVerified: false, destinationPermissionsVerified: false, destinationAccessVerified: false });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'UNRESOLVED', installMappings: [mapping('staff'), mapping('management')], localFile, sha256: digest(bytes), size: bytes.length }] }));
const plan = await prepareResourcePlan(privateRoot, stage, sha);
assert.deepEqual(plan.files.map(({ role }) => role), ['management', 'staff']);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
assert.equal((await fs.readFile(path.join(stage, 'management', localFile))).toString(), bytes.toString());
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
test('portable shell installer executes the reviewed staged-plan interface with synthetic files', async () => {
const { root, privateRoot, hubfs, stage } = await fixture('aia-portal-install-');
try {
const { bytes } = await writeApprovedResource(privateRoot, hubfs);
const plan = await prepareResourcePlan(privateRoot, stage, sha);
await fs.mkdir(path.join(stage, 'management'), { recursive: true });
const planFile = path.join(root, 'approved-plan.tsv');
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
const fakeBin = path.join(root, 'fake-bin');
const log = path.join(root, 'ownership.log');
await fs.mkdir(fakeBin);
await fs.writeFile(path.join(fakeBin, 'id'), '#!/bin/sh\n[ "$1" = -u ] && { echo 0; exit 0; }\nexec /usr/bin/id "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'getent'), '#!/bin/sh\n[ "$1" = group ] && [ "$2" = nginx ] && { echo "nginx:x:999:"; exit 0; }\nexec /run/current-system/sw/bin/getent "$@"\n', { mode: 0o755 });
await fs.writeFile(path.join(fakeBin, 'install'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\nwhile [ "$#" -gt 0 ]; do\n case "$1" in -o|-g|-m) shift 2 ;; *) break ;; esac\ndone\nexec /usr/bin/install -m 0640 "$@"\n`, { mode: 0o755 });
const script = path.join(root, 'portal-resource-install.sh');
const source = await fs.readFile(scriptSource, 'utf8');
assert.ok(!source.includes('node '));
await fs.writeFile(script, source.replace('ROOT=/srv/www/azinstitute4autism.com', `ROOT=${root}`), { mode: 0o755 });
const result = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.equal(result.status, 0, result.stderr);
assert.equal((await fs.readFile(path.join(root, 'secure-resources/staff/guide.pdf'))).toString(), bytes.toString());
assert.match(await fs.readFile(log, 'utf8'), /-o root -g nginx -m 0640/);
const nonRoot = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH } });
assert.notEqual(nonRoot.status, 0);
assert.match(nonRoot.stderr, /requires root/);
await fs.writeFile(path.join(stage, 'staff', 'unexpected.pdf'), 'unexpected');
const rejected = spawnSync('/bin/sh', [script, stage, planFile], { encoding: 'utf8', env: { ...process.env, PATH: process.env.PATH, AIA_PORTAL_INSTALL_TEST_PATH: fakeBin } });
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /Unexpected staged resource/);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
+24
View File
@@ -0,0 +1,24 @@
/** Reactivate one retained, reviewed Contabo release. No build or NixOS action. */
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { sshTarget } from './deploy.mjs';
const sha = /^[0-9a-f]{40}$/;
function fail(message) { throw new Error(message); }
function run(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
if (result.stdout) process.stdout.write(result.stdout);
}
function main() {
const [release, confirmation] = process.argv.slice(2);
if (!sha.test(release ?? '') || confirmation !== '--confirm' || process.argv.length !== 4) fail('Usage: npm run deploy:rollback -- FULL_APPROVED_SHA --confirm');
const [ssh, ...options] = sshTarget(process.env.AIA_DEPLOY_HOST, process.env.AIA_DEPLOY_SSH_IDENTITY);
// The release manager verifies the stored release and manifest before its
// atomic symlink update. The SHA is strictly validated before interpolation.
run(ssh, [...options, process.env.AIA_DEPLOY_HOST, `aia-website-release rollback /root/aia-site-${release}.manifest.json && readlink /srv/www/azinstitute4autism.com/www/dist`]);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { main(); } catch (error) { console.error(`Rollback stopped: ${error.message}`); process.exitCode = 1; }
}
+257
View File
@@ -0,0 +1,257 @@
/**
* 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 } from '../src/utils/portal-crypto.mjs';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
import { assertProductionReadiness } from './portal-production.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));
// Build the sentinel in memory so this guardrail can itself be included in
// the server-validated source snapshot without looking like a PEM body.
if (bytes.includes(Buffer.from(['PRIVATE', ' KEY-----'].join('')))) 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];
}
function validHost(host) {
return typeof host === 'string' && /^[A-Za-z0-9_.:@\-\[\]]+$/.test(host);
}
function usage() {
return [
'Usage:',
' npm run deploy -- --dry-run [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
' npm run deploy -- --confirm [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
'With no positional arguments, use AIA_DEPLOY_HOST, AIA_DEPLOY_SSH_IDENTITY and AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE.',
].join('\n');
}
export function sshTarget(host, identity, { allowSshConfig = false } = {}) {
if (!validHost(host)) fail('Deployment SSH host is invalid');
if (identity === undefined) {
if (!allowSshConfig) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
return ['ssh'];
}
if (!identity || !path.isAbsolute(identity)) fail('Deployment 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 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 };
}
export async function portalInput(ciphertext, directory, env = process.env) {
if (!ciphertext) return syntheticPayload(directory);
const privateRoot = path.resolve(env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(ciphertext));
const readiness = await assertProductionReadiness(privateRoot, ciphertext);
return { file: ciphertext, payload: readiness.payload, synthetic: false };
}
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', 'tools/counters.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 });
}
export function parseArgs(argv) {
const allowed = new Set(['--dry-run', '--confirm']);
const flags = argv.filter((arg) => arg.startsWith('--'));
const positional = argv.filter((arg) => !arg.startsWith('--'));
if (flags.some((arg) => !allowed.has(arg)) || new Set(flags).size !== flags.length) fail(usage());
if ((flags.includes('--dry-run') && flags.includes('--confirm')) || (!flags.includes('--dry-run') && !flags.includes('--confirm'))) fail(`${usage()}\nChoose exactly one of --dry-run or --confirm.`);
if (![0, 2, 3].includes(positional.length) || positional.some((arg) => !arg)) fail(`${usage()}\nProvide either zero, two, or three positional arguments.`);
return { dryRun: flags.includes('--dry-run'), confirm: flags.includes('--confirm'), positional };
}
export function deploymentInputs(options, env = process.env) {
const { positional } = options;
if (!positional.length) {
return {
host: env.AIA_DEPLOY_HOST,
identity: env.AIA_DEPLOY_SSH_IDENTITY || undefined,
ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE,
allowSshConfig: false,
source: 'environment',
};
}
const [host, maybeIdentity, maybeCiphertext] = positional;
const identity = positional.length === 3 ? maybeIdentity : undefined;
const ciphertext = positional.length === 3 ? maybeCiphertext : maybeIdentity;
if (!validHost(host)) fail('Positional SSH host is invalid');
if (identity !== undefined && !path.isAbsolute(identity)) fail('Positional SSH identity must be an absolute path');
if (!path.isAbsolute(ciphertext)) fail('Positional portal ciphertext path must be absolute');
return { host, identity, ciphertext, allowSshConfig: positional.length === 2, source: 'positional' };
}
function assertRemoteInputs(inputs) {
if (!validHost(inputs.host)) fail(inputs.source === 'environment' ? 'AIA_DEPLOY_HOST is required and must be valid for --confirm' : 'Positional SSH host is invalid');
if (!inputs.allowSshConfig && (!inputs.identity || !path.isAbsolute(inputs.identity))) fail('AIA_DEPLOY_SSH_IDENTITY is required and must be an absolute path for environment-based deployment');
}
function shellQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
export function rsyncTransport(sshArguments) {
return sshArguments.map(shellQuote).join(' ');
}
async function remoteDeploy({ host, identity, allowSshConfig, sha, source, dist, manifest }) {
const [ssh, ...sshOptions] = sshTarget(host, identity, { allowSshConfig });
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 = rsyncTransport([ssh, ...sshOptions]);
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 inputs = deploymentInputs(options);
const sha = assertPreflight({ requirePrimary: !options.dryRun });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
try {
const portal = await portalInput(inputs.ciphertext, temporary);
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE or a positional ciphertext path');
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
if (!options.dryRun) assertRemoteInputs(inputs);
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'live', PUBLIC_AIA_API_BASE: 'https://api.azinstitute4autism.com', PUBLIC_COUNTERS_LIVE_APPROVED: 'true', 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 }, 'live');
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({ ...inputs, 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; });
}
+89
View File
@@ -0,0 +1,89 @@
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 { deploymentInputs, parseArgs, portalInput, releaseManifest, rsyncArguments, rsyncTransport, 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/);
});
test('deployment accepts a positional OpenSSH alias and ciphertext ahead of environment settings', () => {
const options = parseArgs(['--dry-run', 'aiar', '/private files/portal ciphertext.json']);
const inputs = deploymentInputs(options, {
AIA_DEPLOY_HOST: 'wrong.example',
AIA_DEPLOY_SSH_IDENTITY: '/wrong/key',
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/wrong/payload.json',
});
assert.deepEqual(inputs, {
host: 'aiar', identity: undefined, ciphertext: '/private files/portal ciphertext.json', allowSshConfig: true, source: 'positional',
});
assert.deepEqual(sshTarget(inputs.host, inputs.identity, { allowSshConfig: inputs.allowSshConfig }), ['ssh']);
assert.equal(rsyncTransport(['ssh']), "'ssh'");
});
test('deployment accepts positional explicit identities without shell-splitting paths', () => {
const options = parseArgs(['--confirm', 'root@66.94.121.180', '/keys/AIA Marketing', '/private files/portal ciphertext.json']);
const inputs = deploymentInputs(options, {});
assert.equal(inputs.allowSshConfig, false);
assert.equal(inputs.identity, '/keys/AIA Marketing');
assert.deepEqual(sshTarget(inputs.host, inputs.identity), ['ssh', '-i', '/keys/AIA Marketing', '-o', 'IdentitiesOnly=yes']);
assert.equal(rsyncTransport(sshTarget(inputs.host, inputs.identity)), "'ssh' '-i' '/keys/AIA Marketing' '-o' 'IdentitiesOnly=yes'");
});
test('deployment retains environment-only compatibility and rejects malformed positional input', () => {
const env = {
AIA_DEPLOY_HOST: 'root@host.example',
AIA_DEPLOY_SSH_IDENTITY: '/absolute/key',
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/private/payload.json',
};
assert.deepEqual(deploymentInputs(parseArgs(['--confirm']), env), {
host: env.AIA_DEPLOY_HOST, identity: env.AIA_DEPLOY_SSH_IDENTITY, ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE, allowSshConfig: false, source: 'environment',
});
assert.throws(() => parseArgs(['--dry-run', 'aiar']), /zero, two, or three positional/);
assert.throws(() => parseArgs(['--dry-run', '--confirm']), /Choose exactly one/);
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative.json']), {}), /must be absolute/);
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative-key', '/private/payload.json']), {}), /must be an absolute path/);
});
test('a supplied ciphertext path must exist and be safe before a deployment build starts', async () => {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-missing-ciphertext-'));
try {
await assert.rejects(portalInput(path.join(temporary, 'missing.json'), temporary), /Portal ciphertext is unavailable or unsafe/);
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
});
+121 -61
View File
@@ -1,11 +1,15 @@
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */ /** Owner-run private portal preparation. Access codes are accepted only by hidden TTY prompts. */
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs'; import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { applyRoleMappings, assertProductionMappings, manifestDigest, payloadDigest, summarizeMappings } from './portal-production.mjs';
const defaultRoot = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || defaultRoot);
const timestamp = () => new Date().toISOString().replaceAll(':', '-');
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
async function hiddenPrompt(label) { async function hiddenPrompt(label) {
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required'); if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
process.stderr.write(label); process.stderr.write(label);
@@ -24,67 +28,123 @@ async function hiddenPrompt(label) {
process.stdin.on('data', onData); process.stdin.on('data', onData);
}); });
} }
async function main() { async function readJson(file) { return JSON.parse(await fs.readFile(file, 'utf8')); }
const [command, source, ...extra] = process.argv.slice(2); async function atomicPrivateJson(file, value) {
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt'); const temporary = `${file}.tmp-${process.pid}`;
// No scanning for credentials. The original is read only; never copied into Git. await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8'))); await fs.chmod(temporary, 0o600);
await fs.mkdir(root, { recursive: true, mode: 0o700 }); await fs.rename(temporary, file);
await fs.chmod(root, 0o700);
const manifestPath = path.join(root, 'manifest.json');
let previous = { resources: [] };
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
const nextStreams = [], unlocked = [];
let rewritten = 0;
for (let index = 0; index < payload.streams.length; index++) {
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
const stream = payload.streams[index];
let html = await decryptPortalStream(stream, pass);
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
// Reject identical credentials across views to preserve stream separation.
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
for (const ref of resourceReferences(html, stream.id)) {
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
record.references.push(ref); resources.set(ref.sourceUrl, record);
} }
if (command === 'rewrite') { async function backup(file, label) {
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id))); try { await fs.copyFile(file, path.join(privateRoot, `${label}-${timestamp()}.json`), fs.constants.COPYFILE_EXCL); }
html = result.html; rewritten += result.count; catch (error) { if (error.code !== 'ENOENT') throw error; }
const next = await encryptPortalStream(stream.id, html, pass);
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
nextStreams.push(next);
} }
unlocked.push(stream.id); html = ''; pass = ''; async function sourcePayload(source) {
if (!path.isAbsolute(source)) throw new Error('The private legacy source path must be absolute');
return validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8')));
} }
const stamp = new Date().toISOString().replaceAll(':', '-'); async function identifyStreams(payload) {
if (command === 'collect') { const staffCode = await hiddenPrompt('Staff access code: ');
const approvedOrigins = Array.isArray(previous.approvedOrigins) const managementCode = await hiddenPrompt('Management access code: ');
? previous.approvedOrigins.filter((origin) => { const identify = async (code) => {
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; } const unlocked = [];
catch { return false; } for (const stream of payload.streams) if (await decryptPortalStream(stream, code) !== null) unlocked.push(stream.id);
}) if (unlocked.length !== 1) throw new Error('Each owner code must authenticate exactly one encrypted stream');
: undefined; return unlocked[0];
for (const [url, record] of resources) {
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
}
const manifest = {
version: 1,
updated: stamp,
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
views: unlocked,
resources: [...resources.values()],
}; };
// Retain each previous manifest; no original evidence is overwritten. const staff = await identify(staffCode), management = await identify(managementCode);
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; } if (staff === management) throw new Error('Staff and management codes must identify different streams');
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 }); return { staff, management, staffCode, managementCode };
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
} else {
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
} }
const list = [...resources.values()]; async function identifyCommand(payload) {
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2)); const roles = await identifyStreams(payload);
const metadata = { schema: 1, payloadSha256: payloadDigest(payload), staff: roles.staff, management: roles.management, verifiedAt: new Date().toISOString() };
roles.staffCode = ''; roles.managementCode = '';
await atomicPrivateJson(path.join(privateRoot, 'stream-role-map.json'), metadata);
console.log(JSON.stringify({ identified: true, staffStream: metadata.staff, managementStream: metadata.management }, null, 2));
} }
main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; }); async function loadRoleMap(payload) {
const roles = await readJson(path.join(privateRoot, 'stream-role-map.json'));
if (roles.schema !== 1 || roles.payloadSha256 !== payloadDigest(payload) || !roles.staff || !roles.management || roles.staff === roles.management) throw new Error('The private stream-role map is missing or does not match this ciphertext');
return roles;
}
async function mapCommand(payload, approve) {
const roles = await loadRoleMap(payload);
const manifestPath = path.join(privateRoot, 'manifest.json');
const manifest = await readJson(manifestPath);
const summary = summarizeMappings(manifest, roles);
const report = {
schema: 1,
generatedAt: new Date().toISOString(),
staffOnly: summary.staffOnly,
managementOnly: summary.managementOnly,
shared: summary.shared,
ambiguous: summary.ambiguous,
proposed: summary.proposed.map(({ localFile, roles: proposedRoles, filename }) => ({ localFile, roles: proposedRoles, filename })),
};
await atomicPrivateJson(path.join(privateRoot, `mapping-summary-${timestamp()}.json`), report);
if (approve) {
if (summary.ambiguous) throw new Error('Ambiguous resources require specific private resolution before mapping approval');
await backup(manifestPath, 'manifest-before-role-mapping');
await atomicPrivateJson(manifestPath, applyRoleMappings(manifest, roles, { approveShared: true }));
}
console.log(JSON.stringify({ staffOnly: report.staffOnly, managementOnly: report.managementOnly, shared: report.shared, ambiguous: report.ambiguous, approved: approve, automaticallyMapped: approve ? summary.proposed.length : 0 }, null, 2));
}
async function rewriteProduction(payload) {
const roles = await loadRoleMap(payload);
const manifestPath = path.join(privateRoot, 'manifest.json');
const manifest = await readJson(manifestPath);
const mappingCount = assertProductionMappings(manifest);
const codes = await identifyStreams(payload);
if (codes.staff !== roles.staff || codes.management !== roles.management) throw new Error('Entered codes do not match the previously verified stream-role relationship');
const codeByRole = { staff: codes.staffCode, management: codes.managementCode };
const roleByStream = new Map([[roles.staff, 'staff'], [roles.management, 'management']]);
const rewrittenStreams = []; let rewritten = 0; let legacyHubfsReferences = 0;
try {
for (const stream of payload.streams) {
const role = roleByStream.get(stream.id);
if (!role) throw new Error('Encrypted payload includes an unidentified stream');
let html = await decryptPortalStream(stream, codeByRole[role]);
if (html === null) throw new Error('Owner code no longer authenticates the expected stream');
const result = rewriteResources(html, manifest.resources, role);
rewritten += result.count;
legacyHubfsReferences += resourceReferences(result.html, stream.id).length;
if (legacyHubfsReferences) throw new Error('Legacy employee-resource links remain after rewriting');
const encrypted = await encryptPortalStream(stream.id, result.html, codeByRole[role]);
if (await decryptPortalStream(encrypted, codeByRole[role]) !== result.html) throw new Error('Private round-trip verification failed');
const otherRole = role === 'staff' ? 'management' : 'staff';
if (await decryptPortalStream(encrypted, codeByRole[otherRole]) !== null) throw new Error('Encrypted view role separation failed');
rewrittenStreams.push(encrypted);
html = '';
}
} finally { codes.staffCode = ''; codes.managementCode = ''; codeByRole.staff = ''; codeByRole.management = ''; }
const finalPayload = validatePortalPayload({ ...payload, streams: rewrittenStreams });
const output = path.join(privateRoot, 'approved-production-ciphertext.json');
await backup(output, 'approved-production-ciphertext-before-rewrite');
await atomicPrivateJson(output, finalPayload);
const readiness = {
schema: 1,
generatedAt: new Date().toISOString(),
payloadSha256: payloadDigest(finalPayload),
manifestSha256: manifestDigest(manifest),
protectedMappings: mappingCount,
legacyHubfsReferences,
rewrittenReferences: rewritten,
};
await atomicPrivateJson(path.join(privateRoot, 'production-readiness.json'), readiness);
console.log(JSON.stringify({ installed: true, payloadSha256: readiness.payloadSha256, protectedMappings: mappingCount, rewrittenReferences: rewritten, legacyHubfsReferences }, null, 2));
}
async function main() {
const [command, source, ...flags] = process.argv.slice(2);
const valid = new Set(['identify', 'propose-mappings', 'approve-mappings', 'rewrite-production']);
if (!valid.has(command) || !source || flags.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs identify|propose-mappings|approve-mappings|rewrite-production /absolute/private/footer-source.txt');
await fs.mkdir(privateRoot, { recursive: true, mode: 0o700 });
await fs.chmod(privateRoot, 0o700);
const payload = await sourcePayload(source);
if (command === 'identify') await identifyCommand(payload);
else if (command === 'propose-mappings') await mapCommand(payload, false);
else if (command === 'approve-mappings') await mapCommand(payload, true);
else await rewriteProduction(payload);
}
main().catch(() => { console.error('Private portal preparation stopped. Check codes, source, mapping evidence and private-file permissions locally. No credential or decrypted content details are logged.'); process.exitCode = 1; });
@@ -0,0 +1,32 @@
{
"contract": "AIAMarketing/contabo-vps website-release.py manifest schema",
"contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f",
"input": {
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"files": {
"index.html": "<title>Synthetic</title>",
"_astro/entry.abcdefgh.js": "export {}"
}
},
"expected": {
"directories": [
"_astro"
],
"files": {
"_astro/entry.abcdefgh.js": {
"sha256": "f4c5cf9bb78e85f15dc27180260637cf24b2a24bc39e0788783a3accc4dde614",
"size": 9
},
"index.html": {
"sha256": "3a958d616a9a1f0d9229cff90395efb5155029a7f31d018c198c0c7d4cf28989",
"size": 24
}
},
"immutableAssets": [
"_astro/entry.abcdefgh.js"
],
"schema": 1,
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"treeSha256": "4ca2127ce195f1975cbe85fdea800fb3177972afa3a1692794ba403939288694"
}
}
+17 -5
View File
@@ -1075,7 +1075,7 @@ test('Phase 7 privacy page preserves policy sections and reciprocal eligible nav
}); });
test('Phase 9 off-mode form surfaces localize Spanish without enabling submission', () => { 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 $ = load(artifacts.get('staging').html.get(route));
const form = $('[data-aia-form]'); const form = $('[data-aia-form]');
assert.equal(form.length, 1, route); 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/); 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')); const $ = load(artifacts.get('staging').html.get('/es/contact'));
assert.match($('.contact-map-section').attr('aria-label'), /^Mapa/); assert.match($('.openstreetmap-section').attr('aria-label'), /^Mapa/);
assert.equal($('#aia-contact-map').attr('data-zoom-in'), 'Acercar'); assert.equal($('#aia-openstreetmap').attr('data-zoom-in'), 'Acercar');
assert.equal($('#aia-contact-map').attr('data-zoom-out'), 'Alejar'); assert.equal($('#aia-openstreetmap').attr('data-zoom-out'), 'Alejar');
assert.match($('#aia-contact-map').attr('data-tile-alt'), /^Mapa de OpenStreetMap/); 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', () => { test('Phase 7 author labels and Arabic credential isolation preserve archive SEO', () => {
+53 -8
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fg from 'fast-glob'; import fg from 'fast-glob';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const readText = async (file) => fs.readFile(file, 'utf8'); const readText = async (file) => fs.readFile(file, 'utf8');
@@ -36,6 +37,12 @@ function hasTerminalSlash(url) {
return pathname !== '/' && pathname.endsWith('/'); 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([ const formAContactLandingUrls = new Map([
['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'], ['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'],
['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'], ['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'],
@@ -78,20 +85,27 @@ function formAAdapters(html) {
return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]); return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]);
} }
function unsafeSignals(html, allowApprovedFormA = false) { function unsafeSignals(html, allowApprovedFormA = false, allowApprovedCounters = false) {
const signals = []; const signals = [];
if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe'); if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier'); if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier');
if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script'); if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script');
if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode'); if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode');
if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin'); const counterReviewed = allowApprovedCounters
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content'); ? html.replace(/<div\b[^>]*\bdata-counter\b[^>]*>/gi, (tag) =>
tag.replace('data-api-base="https://api.azinstitute4autism.com"', ''))
: html;
if (/api\.azinstitute4autism\.com/i.test(counterReviewed)) signals.push('production API origin');
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE\sKEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content');
return signals; return signals;
} }
export async function verifyArtifact(root, mode = 'staging', forms = 'live') { export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }, counters = 'off') {
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 (!['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 (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
if (!['live', 'off', 'mock'].includes(counters)) throw new Error('Artifact counters 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 formAEnabled = forms === 'live';
const stagingPolicy = mode === 'staging'; const stagingPolicy = mode === 'staging';
const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map(); const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map();
@@ -106,6 +120,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
let secretSignals = []; let secretSignals = [];
let formAIframes = 0; let formAIframes = 0;
let deferredFormAIframes = 0; let deferredFormAIframes = 0;
let counterInstances = 0;
for (const file of htmlFiles) { for (const file of htmlFiles) {
const html = await readText(file); const html = await readText(file);
@@ -118,7 +133,20 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash); const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined; const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined;
const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined; const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined;
const signals = unsafeSignals(html, formAEnabled); const signals = unsafeSignals(html, formAEnabled, counters === 'live');
const countersOnPage = [...html.matchAll(/<div\b[^>]*\bdata-counter\b[^>]*>/gi)].map((match) => match[0]);
for (const counter of countersOnPage) {
if (counters === 'live') {
if (!counter.includes('data-counter-mode="live"')
|| !counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
errors.push(`${route}: live counter lacks the approved mode or API origin`);
}
} else if (counter.includes('data-counter-mode="live"')
|| counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
errors.push(`${route}: live counter appears in a ${counters} artifact`);
}
}
counterInstances += countersOnPage.length;
const jotformFrames = iframeSources(html).filter((src) => { const jotformFrames = iframeSources(html).filter((src) => {
try { try {
return new URL(src).origin === 'https://form.jotform.com'; return new URL(src).origin === 'https://form.jotform.com';
@@ -142,7 +170,18 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
} }
if (expectedFormAUrl && deferredIframeSources(html).length) errors.push(`${route}: unexpected deferred frame`); 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 (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 (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`); if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`); if (!robots) errors.push(`${route}: missing robots directive`);
@@ -192,6 +231,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index'); if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index');
} }
if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`); if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`);
if (counters === 'live' && counterInstances === 0) errors.push('live artifact contains no article counters');
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`); if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort(); const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
@@ -199,7 +239,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
const digest = createHash('sha256'); const digest = createHash('sha256');
for (const file of files) { for (const file of files) {
const relative = path.relative(root, file).replaceAll(path.sep, '/'); 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)) { if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
const contents = await fs.readFile(file, 'utf8'); const contents = await fs.readFile(file, 'utf8');
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative); if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
@@ -221,6 +261,8 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
return { return {
mode, mode,
forms, forms,
counters,
portal: portal.mode,
htmlRoutes: htmlFiles.length, htmlRoutes: htmlFiles.length,
sitemapUrls: sitemapRoutes.length, sitemapUrls: sitemapRoutes.length,
hreflangLinks, hreflangLinks,
@@ -241,7 +283,10 @@ 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 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 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 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 counters = process.argv.find((arg) => arg.startsWith('--counters='))?.slice('--counters='.length) ?? 'off';
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 }, counters);
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
if (!result.passed) process.exitCode = 1; if (!result.passed) process.exitCode = 1;
} }
+37 -1
View File
@@ -4,6 +4,8 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { verifyArtifact } from './phase10-artifact-verify.mjs'; 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') { async function fixture(mode = 'staging', forms = 'live') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-')); const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-'));
@@ -108,6 +110,19 @@ test('artifact rejects production API origin even when Form A is approved', asyn
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals'))); assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
}); });
test('live counters allow only the reviewed API origin and explicit live mode', async () => {
const root = await fixture('indexing');
await fs.appendFile(path.join(root, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://api.azinstitute4autism.com"></div>');
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'live')).passed, true);
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'off')).passed, false);
const wrong = await fixture('indexing');
await fs.appendFile(path.join(wrong, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://other.invalid"></div>');
const result = await verifyArtifact(wrong, 'indexing', 'live', { mode: 'none' }, 'live');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('approved mode or API origin')));
});
test('artifact rejects Form A identity outside the exact approved HTML routes', async () => { test('artifact rejects Form A identity outside the exact approved HTML routes', async () => {
const root = await fixture('staging'); const root = await fixture('staging');
await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact'); await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact');
@@ -139,10 +154,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>'); await fs.appendFile(path.join(root, 'index.html'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
const result = await verifyArtifact(root, 'staging'); const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false); 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'))); 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 () => { test('artifact rejects extra routes, wrong origins and prematurely loaded drawers', async () => {
const root = await fixture(); const root = await fixture();
@@ -160,4 +195,5 @@ test('artifact rejects extra routes, wrong origins and prematurely loaded drawer
test('obsolete artifact modes are rejected with separate publication/form guidance', async () => { test('obsolete artifact modes are rejected with separate publication/form guidance', async () => {
await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/); await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/);
await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/); await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/);
await assert.rejects(verifyArtifact('/unused','staging','live',{ mode: 'none' },'invalid'), /counters must be live/);
}); });
+15 -10
View File
@@ -20,7 +20,7 @@ test('counter configuration defaults off and requires explicit approval', () =>
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off'); assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off');
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live'); assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live');
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off'); assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'off'); assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'live');
assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock'); assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock');
}); });
@@ -45,12 +45,12 @@ test('Form A meeting preview requires explicit mock mode and remains staging-onl
assert.equal(formAPreviewEnabled('TRUE', 'FALSE', 'mock'), true); assert.equal(formAPreviewEnabled('TRUE', 'FALSE', 'mock'), true);
}); });
test('form mapping covers twelve native instances and five landing journeys', () => { test('form mapping covers eleven native instances and five landing journeys', () => {
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 12); assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 11);
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'landing-journey').length, 5); assert.equal(formRouteMapping.filter((entry) => entry.kind === 'landing-journey').length, 5);
assert.equal(logicalFormForRoute('/contact'), 'general'); assert.equal(logicalFormForRoute('/contact'), 'general');
assert.equal(logicalFormForRoute('/es/client-consultation'), 'service'); 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']) { for (const route of ['/es/aba-therapy', '/es/autism-evaluations', '/es/learner-social-club']) {
assert.equal(logicalFormForRoute(route), 'service'); assert.equal(logicalFormForRoute(route), 'service');
} }
@@ -73,10 +73,15 @@ test('inactive source has no native form or live vendor destination', () => {
assert.match(sources, /data-form-mode/); assert.match(sources, /data-form-mode/);
}); });
test('counter source has no implicit production fallback or credentialed fetch', () => { test('counter source requires explicit live configuration and omits credentials', () => {
const source = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8'); const component = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
assert.doesNotMatch(source, /api\.azinstitute4autism\.com/); const runtime = fs.readFileSync('src/scripts/likes-views.ts', 'utf8');
assert.doesNotMatch(source, /credentials:\s*['"]include/); const deploy = fs.readFileSync('tools/deploy.mjs', 'utf8');
assert.doesNotMatch(source, /method:\s*['"](?:POST|DELETE)/); assert.doesNotMatch(component, /api\.azinstitute4autism\.com/);
assert.match(source, /data-counter-mode/); assert.doesNotMatch(runtime, /credentials:\s*['"]include/);
assert.match(runtime, /credentials:\s*['"]omit/);
assert.match(component, /data-counter-mode/);
assert.match(deploy, /PUBLIC_COUNTERS_MODE:\s*'live'/);
assert.match(deploy, /PUBLIC_COUNTERS_LIVE_APPROVED:\s*'true'/);
assert.match(deploy, /PUBLIC_AIA_API_BASE:\s*'https:\/\/api\.azinstitute4autism\.com'/);
}); });
+127
View File
@@ -0,0 +1,127 @@
/** Private employee-portal mapping and production-readiness helpers.
* These helpers deliberately never decrypt portal streams or read access codes.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const internal = new Set(['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL']);
const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..';
const hash = (value) => createHash('sha256').update(value).digest('hex');
export const payloadDigest = (payload) => hash(Buffer.from(JSON.stringify(validatePortalPayload(payload))));
export const manifestDigest = (manifest) => hash(Buffer.from(JSON.stringify(manifest)));
function roleForClassification(classification) {
if (classification === 'STAFF-INTERNAL') return 'staff';
if (classification === 'MANAGEMENT-INTERNAL') return 'management';
return null;
}
function safeDeliveryPath(role, value) {
const prefix = `/${role}-resources/`;
if (typeof value !== 'string' || !value.startsWith(prefix)) return false;
const name = value.slice(prefix.length);
return safeName(name) && value === `${prefix}${name}`;
}
export function mappingsFor(record) {
if (Array.isArray(record.installMappings)) return record.installMappings;
const role = roleForClassification(record.classification);
return role ? [{
role,
classification: record.classification,
deliveryPath: record.deliveryPath,
mappingApproved: record.mappingApproved,
accessPolicyApproved: record.accessPolicyApproved,
rewriteApproved: record.rewriteApproved,
destinationInstalled: record.destinationInstalled,
destinationHashVerified: record.destinationHashVerified,
destinationPermissionsVerified: record.destinationPermissionsVerified,
destinationAccessVerified: record.destinationAccessVerified,
}] : [];
}
export function approvedMapping(record, role) {
const mapping = mappingsFor(record).find((candidate) => candidate.role === role);
if (!mapping || !internal.has(mapping.classification) || !safeDeliveryPath(role, mapping.deliveryPath)) return null;
if (!mapping.mappingApproved || !mapping.accessPolicyApproved || !mapping.rewriteApproved) return null;
return mapping;
}
export function deriveDestinationName(record) {
let originalPath = record.originalPath;
try { originalPath ||= new URL(record.sourceUrl).pathname; } catch { return null; }
const extension = path.extname(originalPath).toLowerCase();
if (!/^[.][a-z0-9]{1,10}$/.test(extension) || !safeName(record.localFile)) return null;
return `${record.localFile}${extension}`;
}
export function summarizeMappings(manifest, streamRoles) {
if (!streamRoles || !streamRoles.staff || !streamRoles.management || streamRoles.staff === streamRoles.management) throw new Error('A distinct staff/management stream-role mapping is required');
const summary = { staffOnly: 0, managementOnly: 0, shared: 0, ambiguous: 0, proposed: [] };
for (const record of manifest.resources || []) {
const views = new Set((record.references || []).map((reference) => reference.view));
const roles = new Set([...views].map((view) => view === streamRoles.staff ? 'staff' : view === streamRoles.management ? 'management' : null));
if (roles.has(null) || !roles.size || !deriveDestinationName(record)) { summary.ambiguous++; continue; }
if (roles.size === 2) summary.shared++; else if (roles.has('staff')) summary.staffOnly++; else summary.managementOnly++;
summary.proposed.push({ sourceUrl: record.sourceUrl, localFile: record.localFile, roles: [...roles].sort(), filename: deriveDestinationName(record) });
}
return summary;
}
export function applyRoleMappings(manifest, streamRoles, { approveShared = false } = {}) {
const summary = summarizeMappings(manifest, streamRoles);
const resources = (manifest.resources || []).map((record) => {
const proposal = summary.proposed.find((entry) => entry.sourceUrl === record.sourceUrl);
if (!proposal || (proposal.roles.length > 1 && !approveShared)) return { ...record };
const installMappings = proposal.roles.map((role) => ({
role,
classification: role === 'staff' ? 'STAFF-INTERNAL' : 'MANAGEMENT-INTERNAL',
deliveryPath: `/${role}-resources/${proposal.filename}`,
mappingApproved: true,
accessPolicyApproved: true,
rewriteApproved: true,
// Server-installation evidence is intentionally separate and remains false.
destinationInstalled: false,
destinationHashVerified: false,
destinationPermissionsVerified: false,
destinationAccessVerified: false,
}));
return {
...record,
references: (record.references || []).map((reference) => ({
...reference,
role: reference.view === streamRoles.staff ? 'staff' : reference.view === streamRoles.management ? 'management' : undefined,
})),
classification: installMappings.length === 1 ? installMappings[0].classification : 'UNRESOLVED',
installMappings,
mappingStatus: installMappings.length === 1 ? 'approved-for-installation' : 'approved-shared-role-installation',
};
});
return { ...manifest, resources, mappingSchema: 2, mappingUpdatedAt: new Date().toISOString() };
}
export function assertProductionMappings(manifest) {
let count = 0;
for (const record of manifest.resources || []) {
const references = record.references || [];
if (!references.length) continue;
const roles = new Set(references.map((ref) => ref.role).filter(Boolean));
// Role labels are only required after the private stream-role step.
if (!roles.size) throw new Error('Private manifest lacks verified stream-to-role references');
for (const role of roles) {
if (!approvedMapping(record, role)) throw new Error('Private manifest has an unresolved or unapproved protected resource mapping');
count++;
}
}
if (!count) throw new Error('Private manifest has no approved protected resource mappings');
return count;
}
export async function readPrivateJson(file, label) {
const stat = await fs.lstat(file).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) throw new Error(`${label} is unavailable or unsafe`);
return JSON.parse(await fs.readFile(file, 'utf8'));
}
export async function assertProductionReadiness(privateRoot, payloadFile) {
const payload = validatePortalPayload(await readPrivateJson(payloadFile, 'Portal ciphertext'));
const manifest = await readPrivateJson(path.join(privateRoot, 'manifest.json'), 'Private portal manifest');
const readiness = await readPrivateJson(path.join(privateRoot, 'production-readiness.json'), 'Private production readiness record');
if (readiness.schema !== 1 || readiness.payloadSha256 !== payloadDigest(payload) || readiness.manifestSha256 !== manifestDigest(manifest)) throw new Error('Production portal readiness record does not match the current private ciphertext and manifest');
const mappings = assertProductionMappings(manifest);
if (readiness.protectedMappings !== mappings || readiness.legacyHubfsReferences !== 0) throw new Error('Production portal readiness record does not prove complete protected-link rewriting');
return { payload, manifest, readiness };
}
+128
View File
@@ -0,0 +1,128 @@
#!/bin/sh
# Root-only Contabo-side installer for a reviewed employee-resource plan.
# It deliberately uses the NixOS base shell/coreutils path, not Node.js.
set -eu
fail() { printf '%s\n' "Employee resource installation stopped: $*" >&2; exit 1; }
ROOT=/srv/www/azinstitute4autism.com
ROLES='staff management'
PATH=/run/current-system/sw/bin:/usr/bin:/bin
# The production SSH command uses env -i, so this hook is reachable only from
# the local synthetic-interface test. It never changes the deployed PATH.
case "${AIA_PORTAL_INSTALL_TEST_PATH:-}" in
'') ;;
*/aia-portal-install-*/fake-bin) PATH="$AIA_PORTAL_INSTALL_TEST_PATH:$PATH" ;;
*) fail 'Invalid synthetic installer test path' ;;
esac
export PATH
safe_name() {
value=$1
case "$value" in ''|.|..|.*|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#value}" -le 241 ]
}
valid_hex() {
value=$1 length=$2
[ "${#value}" -eq "$length" ] && printf '%s' "$value" | grep -Eq '^[0-9a-f]+$'
}
regular_file() { [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %h "$1")" -eq 1 ]; }
digest() {
result=$(sha256sum "$1") || return 1
printf '%s\n' "${result%% *}"
}
cleanup() {
status=$?
[ -z "${temporary:-}" ] || rm -rf "$temporary"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
[ "$(id -u)" -eq 0 ] || fail 'Resource installation requires root'
[ "$#" -eq 2 ] || fail 'Usage: portal-resource-install.sh STAGING_DIRECTORY REVIEWED_PLAN.tsv'
stage=$1
plan_file=$2
regular_file "$plan_file" || fail 'Resource plan must be a bounded regular file'
[ "$(stat -c %s "$plan_file")" -le 2000000 ] || fail 'Resource plan is too large'
IFS= read -r header < "$plan_file" || fail 'Resource plan is empty'
[ "$header" = 'AIA_PORTAL_RESOURCE_PLAN_V1' ] || fail 'Unsupported resource plan schema'
commit_line=$(sed -n '2p' "$plan_file")
[ -n "$commit_line" ] || fail 'Resource plan has no source commit'
case "$commit_line" in sourceCommit=*) source_commit=${commit_line#sourceCommit=} ;; *) fail 'Resource plan source commit is invalid' ;; esac
valid_hex "$source_commit" 40 || fail 'Resource plan source commit is invalid'
expected_stage="$ROOT/staging/portal-resources-$source_commit"
[ "$(readlink -f -- "$stage")" = "$expected_stage" ] || fail 'Resource stage is outside the approved root-controlled staging directory'
[ -d "$stage" ] && [ ! -L "$stage" ] || fail 'Resource stage is unsafe'
temporary=$(mktemp -d)
destinations="$temporary/destinations"
staged="$temporary/staged"
: > "$destinations"
: > "$staged"
sed -n '3,$p' "$plan_file" > "$temporary/records"
count=0
line=2
tab=$(printf '\t')
while IFS= read -r record || [ -n "$record" ]; do
line=$((line + 1))
[ -n "$record" ] || fail "Empty resource plan line $line"
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
[ "$kind" = file ] && [ -z "${extra:-}" ] || fail "Invalid resource plan line $line"
case "$role" in staff|management) ;; *) fail "Invalid resource role on line $line" ;; esac
safe_name "$name" && safe_name "$stage_name" || fail "Unsafe resource path on line $line"
valid_hex "$resource_hash" 64 || fail "Invalid resource digest on line $line"
case "$size" in ''|*[!0-9]*) fail "Invalid resource size on line $line" ;; esac
[ "$size" -gt 0 ] && [ "$size" -le 52428800 ] || fail "Invalid resource size on line $line"
destination="$role/$name"
staged_name="$role/$stage_name"
grep -Fqx -- "$destination" "$destinations" && fail "Duplicate resource destination"
grep -Fqx -- "$staged_name" "$staged" && fail "Duplicate staged resource"
printf '%s\n' "$destination" >> "$destinations"
printf '%s\n' "$staged_name" >> "$staged"
count=$((count + 1))
done < "$temporary/records"
[ "$count" -gt 0 ] || fail 'Resource plan has no files'
for role in $ROLES; do
directory="$stage/$role"
[ -d "$directory" ] && [ ! -L "$directory" ] || fail "Missing safe $role staging directory"
find "$directory" -mindepth 1 -print > "$temporary/actual-$role"
while IFS= read -r source; do
relative=${source#"$stage/"}
grep -Fqx -- "$relative" "$staged" || fail "Unexpected staged resource: $relative"
regular_file "$source" || fail "Unsafe staged resource: $relative"
done < "$temporary/actual-$role"
done
# Re-read only validated plan rows, verify every listed input, then install with
# root:nginx ownership and 0640 permissions. mv -n prevents replacement races.
installed=0
while IFS= read -r record || [ -n "$record" ]; do
IFS="$tab" read -r kind role name stage_name resource_hash size extra <<EOF_RECORD
$record
EOF_RECORD
source="$stage/$role/$stage_name"
regular_file "$source" || fail "Missing staged resource: $role/$name"
[ "$(stat -c %s "$source")" -eq "$size" ] && [ "$(digest "$source")" = "$resource_hash" ] || fail "Staged resource digest mismatch: $role/$name"
destination_directory="$ROOT/secure-resources/$role"
destination="$destination_directory/$name"
[ -d "$destination_directory" ] && [ ! -L "$destination_directory" ] || fail "Protected destination directory is unsafe: $role"
if [ -e "$destination" ] || [ -L "$destination" ]; then
regular_file "$destination" || fail "Unsafe existing resource destination: $role/$name"
[ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Refusing to replace existing resource: $role/$name"
printf '%s\n' "already-verified $role/$name"
installed=$((installed + 1))
continue
fi
next="$destination_directory/.${name}.next-$$"
[ ! -e "$next" ] && [ ! -L "$next" ] || fail "Temporary destination already exists: $role/$name"
install -o root -g nginx -m 0640 "$source" "$next" || fail "Unable to stage protected resource: $role/$name"
mv -n -- "$next" "$destination" || fail "Unable to install protected resource: $role/$name"
regular_file "$destination" && [ "$(stat -c %s "$destination")" -eq "$size" ] && [ "$(digest "$destination")" = "$resource_hash" ] || fail "Installed resource verification failed: $role/$name"
printf '%s\n' "installed $role/$name"
installed=$((installed + 1))
done < "$temporary/records"
printf '%s\n' "sourceCommit=$source_commit installed=$installed"
+8 -6
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { approvedMapping } from './portal-production.mjs';
export const sourceOrigin = 'https://www.azinstitute4autism.com'; export const sourceOrigin = 'https://www.azinstitute4autism.com';
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -28,16 +29,17 @@ export function resourceReferences(html, view) {
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url'); for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
return references; return references;
} }
export function rewriteResources(html, records) { export function rewriteResources(html, records, role) {
const mappings = new Map(); const mappings = new Map();
for (const record of records) { for (const record of records) {
if (!record.rewriteApproved) continue; const mapping = role ? approvedMapping(record, role) : null;
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping'); // Public links use their legacy, explicitly approved single-path contract.
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping'); const publicMapping = record.classification === 'PUBLIC-APPROVED' && record.rewriteApproved && record.mappingApproved && typeof record.deliveryPath === 'string' && record.deliveryPath.startsWith('/') && !record.deliveryPath.startsWith('//') && !/[?<>"'\s]/.test(record.deliveryPath);
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control'); if (!mapping && !publicMapping) continue;
const deliveryPath = mapping?.deliveryPath ?? record.deliveryPath;
for (const ref of record.references) { for (const ref of record.references) {
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review'); if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
mappings.set(ref.original, record.deliveryPath); mappings.set(ref.original, deliveryPath);
} }
} }
let count = 0; let count = 0;
+6 -1
View File
@@ -548,7 +548,7 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
['/es/aba-therapy', 'Solicitar una consulta', '/es/client-consultation'], ['/es/aba-therapy', 'Solicitar una consulta', '/es/client-consultation'],
['/es/autism-evaluations', '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'], ['/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'], ['/aba-therapy-intake-process', 'Take the First Step', '/client-consultation'],
['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'], ['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'],
['/services', 'Consult with a Client Advocate', '/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.length, 1, `${route}: ${label}`);
assert.equal(link.attr('href'), href, route); assert.equal(link.attr('href'), href, route);
const target = new URL(href, site + 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); const destination = page(target.pathname);
if (target.hash) assert.equal(destination(`[id="${target.hash.slice(1)}"]`).length, 1, href); if (target.hash) assert.equal(destination(`[id="${target.hash.slice(1)}"]`).length, 1, href);
} }
+72 -9
View File
@@ -16,6 +16,8 @@ import { setTimeout as delay } from 'node:timers/promises';
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live'; const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
assert.ok(['live', 'off', 'mock'].includes(forms)); assert.ok(['live', 'off', 'mock'].includes(forms));
const browserName = process.argv.find(a => a.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
@@ -33,7 +35,7 @@ const routes = [
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' }, { route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
]; ];
const widths = [320, 390, 768, 1440]; const widths = [320, 390, 768, 1440];
const evidence = { forms, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' }; const evidence = { forms, browserName, routes, widths, checks: 0, blocked: [], jotformAttempts: [], resourceRequests: [], screenshots: [], fallbackChecks: [], status: 'failed' };
const reserve = net.createServer(); const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port; const port = reserve.address().port;
@@ -47,9 +49,11 @@ function expectedUrl(locale) {
} }
try { try {
const { chromium } = await import(moduleName); const drivers = await import(moduleName);
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE const driver = drivers[browserName];
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath
? { headless: true, executablePath }
: { headless: true }); : { headless: true });
evidence.browser = browser.version(); evidence.browser = browser.version();
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
@@ -70,17 +74,38 @@ try {
const duration = 30 * 86400000; const duration = 30 * 86400000;
const staffCode = randomBytes(24).toString('hex'); const staffCode = randomBytes(24).toString('hex');
const managementCode = randomBytes(24).toString('hex'); const managementCode = randomBytes(24).toString('hex');
const staffResourceName = 'a'.repeat(64) + '.pdf';
const managementResourceName = 'b'.repeat(64) + '.docx';
const staffResourcePath = `/staff-resources/${staffResourceName}`;
const managementResourcePath = `/management-resources/${managementResourceName}`;
const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>'; const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>';
const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>'; const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>';
const payload = { alg: portalAlgorithm, streams: [ const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">' + styledFixture + staffTemplates, staffCode), await encryptPortalStream('b', `<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="${staffResourcePath}">Staff's Safety &amp; Training: Résumé (2026)!</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">` + styledFixture + staffTemplates, staffCode),
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode), await encryptPortalStream('a', `<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="${managementResourcePath}">Management – Policies / Q&amp;A #1</a>` + styledFixture, managementCode),
] }; ] };
const expectedAuthorization = {
staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'),
management: 'Basic ' + Buffer.from('management:' + managementCode, 'utf8').toString('base64'),
};
const guard = async (context, inject = false) => { const guard = async (context, inject = false) => {
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); }); await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
await context.route('**/*', async (route) => { await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url()); const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) { if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
const resource = /^\/(staff|management)-resources\/([a-f0-9]{64})[.](pdf|docx)$/.exec(url.pathname);
if (resource) {
const role = resource[1];
const authorized = req.headers().authorization === expectedAuthorization[role];
evidence.resourceRequests.push({ role, authorized });
if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } });
return route.fulfill({
status: 200,
contentType: resource[3] === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'cache-control': 'private, no-store' },
body: resource[3] === 'pdf' ? `%PDF-1.7\nSynthetic ${role} document\n` : `Synthetic ${role} document\n`,
});
}
if (inject && url.pathname === '/employee-portal') { if (inject && url.pathname === '/employee-portal') {
const response = await route.fetch(); const response = await route.fetch();
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2'); const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
@@ -327,14 +352,14 @@ try {
await input.fill(staffCode); await input.press('Enter'); await input.fill(staffCode); await input.press('Enter');
await p.locator('[data-portal-content]').waitFor({ state: 'visible' }); await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Synthetic Management|Management fixture only/);
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined); assert.equal(await p.evaluate(() => window.portalUnsafe), undefined);
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0); assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true); assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
assert.equal(await input.inputValue(), ''); assert.equal(await input.inputValue(), '');
const checkPresentation = async (staff = false) => { const checkPresentation = async (staff = false) => {
const content = p.locator('[data-portal-content]'); const content = p.locator('[data-portal-content]');
assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 3); assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 4);
assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0); assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0);
assert.equal(await content.locator('a[href^="javascript:"]').count(), 0); assert.equal(await content.locator('a[href^="javascript:"]').count(), 0);
assert.equal(await content.locator('th[scope="col"]').count(), 2); assert.equal(await content.locator('th[scope="col"]').count(), 2);
@@ -346,7 +371,7 @@ try {
assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)'); assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)');
assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)'); assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)');
assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28)); assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28));
assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)'); assert.equal(await content.locator('.portal-contact-link').first().evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
await button.focus(); await button.focus();
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
@@ -361,6 +386,26 @@ try {
assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count'); assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count');
assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button'); assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button');
assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link'); assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link');
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + staffResourcePath), 401);
await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const staffLink = staffContent.getByRole('link', { name: "Staff's Safety & Training: Résumé (2026)!" });
const staffDownload = p.waitForEvent('download');
await staffLink.click();
assert.equal((await staffDownload).suggestedFilename(), 'Staffs-Safety-and-Training-Resume-2026.pdf');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Staffs-Safety-and-Training-Resume-2026.pdf.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
assert.equal(p.url(), origin + '/employee-portal');
const beforeCrossRole = evidence.resourceRequests.length;
await staffLink.evaluate((link, href) => { link.href = href; }, managementResourcePath);
await staffLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeCrossRole, 'staff credential is not sent to management resources');
assert.deepEqual(await p.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } })), { local: {}, session: {} });
assert.equal(p.url().includes(staffCode), false);
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-synthetic-' + width + '.png'); evidence.screenshots.push('portal-synthetic-' + width + '.png');
@@ -370,6 +415,24 @@ try {
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
await checkPresentation(); await checkPresentation();
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + managementResourcePath), 401);
await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const managementLink = p.getByRole('link', { name: 'Management – Policies / Q&A #1' });
const managementDownload = p.waitForEvent('download');
await managementLink.click();
assert.equal((await managementDownload).suggestedFilename(), 'Management-Policies-Q-and-A-1.docx');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Management-Policies-Q-and-A-1.docx.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
const beforeManagementCrossRole = evidence.resourceRequests.length;
await managementLink.evaluate((link, href) => { link.href = href; }, staffResourcePath);
await managementLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeManagementCrossRole, 'management credential is not sent to staff resources');
assert.equal(p.url().includes(managementCode), false);
assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout'); assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout');
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-management-synthetic-' + width + '.png'); evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
+108 -11
View File
@@ -10,17 +10,28 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { isFormACompletion } from '../src/utils/jotform-completion.ts'; import { isFormACompletion } from '../src/utils/jotform-completion.ts';
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts'; import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
import {
PortalDownloadError,
fetchProtectedPortalResource,
portalDownloadErrorMessage,
portalRoleForStreamId,
protectedPortalResource,
recognizableDownloadFilename,
} from '../src/utils/portal-download.mjs';
import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs';
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 source = {};
const check = (event, frame, active) => isFormACompletion(event, frame, active, const check = (event, frame, active) => isFormACompletion(event, frame, active,
'https://form.jotform.com/262946078314059?formContext=consultation&language=en'); 'https://form.jotform.com/262946078314059?formContext=consultation&language=en');
const data = { action: 'submission-completed', formID: '262946078314059' }; const data = { action: 'submission-completed', formID: '262946078314059' };
const event = { origin: 'https://form.jotform.com', source, data }; const event = { origin: 'https://form.jotform.com', source, data };
assert.equal(check(event, source, true), true); 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); assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true);
for (const change of [ 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' } }, { source: {} }, { source: null }, { data: { ...data, formID: 'other' } },
{ data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } }, { data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } },
{ data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) }, { data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) },
@@ -45,6 +56,65 @@ test('portal presentation translates only explicit button tokens/brand fills and
assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link'); assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link');
}); });
test('portal downloads use only the validated role and an in-memory authorization header', async () => {
assert.equal(portalRoleForStreamId('b'), 'staff');
assert.equal(portalRoleForStreamId('a'), 'management');
assert.equal(portalRoleForStreamId('fixture-staff'), undefined);
const origin = 'https://www.azinstitute4autism.com';
const staff = protectedPortalResource('/staff-resources/' + 'a'.repeat(64) + '.pdf', origin, "Staff's Safety & Training: Résumé (2026)!");
const management = protectedPortalResource('/management-resources/' + 'b'.repeat(64) + '.docx', origin, 'Management – Policies / Q&A #1');
assert.deepEqual(staff, {
role: 'staff', filename: 'a'.repeat(64) + '.pdf',
downloadFilename: 'Staffs-Safety-and-Training-Resume-2026.pdf',
url: origin + '/staff-resources/' + 'a'.repeat(64) + '.pdf',
});
assert.equal(management.downloadFilename, 'Management-Policies-Q-and-A-1.docx');
assert.equal(recognizableDownloadFilename('Client Check-In & Check-Out.xlsx', 'c'.repeat(64) + '.xlsx'), 'Client-Check-In-and-Check-Out.xlsx');
assert.equal(recognizableDownloadFilename('../../', 'd'.repeat(64) + '.pdf'), 'AIA-document.pdf');
assert.equal(recognizableDownloadFilename('No server extension', 'e'.repeat(64)), undefined);
assert.equal(protectedPortalResource('/staff-resources/nested/file.pdf', origin), undefined);
assert.equal(protectedPortalResource('/staff-resources/file.pdf?code=forbidden', origin), undefined);
assert.equal(protectedPortalResource('https://other.invalid/staff-resources/file.pdf', origin), undefined);
const code = 'synthetic browser memory only';
let captured;
const result = await fetchProtectedPortalResource(staff, { role: 'staff', code }, async (url, options) => {
captured = { url, options };
return new Response('synthetic document', {
// nginx may expose the hash name; the approved label remains authoritative.
headers: { 'content-disposition': `attachment; filename="${staff.filename}"` },
});
});
assert.equal(captured.url, staff.url);
assert.equal(captured.url.includes(code), false);
assert.equal(captured.options.method, 'GET');
assert.equal(captured.options.credentials, 'omit');
assert.equal(captured.options.cache, 'no-store');
assert.equal(captured.options.redirect, 'error');
assert.equal(captured.options.referrerPolicy, 'no-referrer');
assert.match(captured.options.headers.Authorization, /^Basic /);
assert.equal(result.filename, 'Staffs-Safety-and-Training-Resume-2026.pdf');
assert.equal(await result.blob.text(), 'synthetic document');
for (const [resource, credential] of [
[management, { role: 'staff', code }],
[staff, { role: 'management', code }],
]) {
let called = false;
await assert.rejects(
fetchProtectedPortalResource(resource, credential, async () => { called = true; return new Response(); }),
(error) => error instanceof PortalDownloadError && error.code === 'role',
);
assert.equal(called, false, 'cross-role credentials are never transmitted');
}
await assert.rejects(
fetchProtectedPortalResource(staff, { role: 'staff', code }, async () => new Response('', { status: 401 })),
(error) => error instanceof PortalDownloadError && error.code === 'unauthorized',
);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('unauthorized')), /session could not authorize/);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('role')), /not available from this portal/);
});
test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => { test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => {
const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8'); const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8');
const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8'); const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8');
@@ -54,7 +124,13 @@ test('staff template presentation follows sanitized headings, not original HubSp
assert.match(renderer, /templatesList\?\.classList\.add\('portal-template-grid'\)/); assert.match(renderer, /templatesList\?\.classList\.add\('portal-template-grid'\)/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/); assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(css, /@media \(max-width: 767px\)/); 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', () => { test('global preference validates expiry, corrupted values and clock anomalies', () => {
@@ -100,12 +176,28 @@ test('resource extraction reconciles duplicate views and rewrites only approved
const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>'; const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>';
const refs = resourceReferences(html, 'synthetic'); const refs = resourceReferences(html, 'synthetic');
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1); assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' }; const record = { references: refs, rewriteApproved: true, mappingApproved: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
assert.throws(() => rewriteResources(html, [record])); assert.equal(rewriteResources(html, [record], 'staff').html, html);
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]); const result = rewriteResources(html, [{ ...record, accessPolicyApproved: true }], 'staff');
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form')); assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
assert.equal(rewriteResources(html, []).html, html); assert.equal(rewriteResources(html, []).html, html);
}); });
test('role-based resource mapping separates source approval from later server verification', () => {
const record = {
sourceUrl: 'https://www.azinstitute4autism.com/hubfs/guide.pdf', originalPath: '/hubfs/guide.pdf',
localFile: 'a'.repeat(64), references: [{ view: 'staff-stream' }, { view: 'management-stream' }],
};
const roles = { staff: 'staff-stream', management: 'management-stream' };
const summary = summarizeMappings({ resources: [record] }, roles);
assert.equal(summary.shared, 1); assert.equal(summary.ambiguous, 0);
const mapped = applyRoleMappings({ resources: [record] }, roles, { approveShared: true });
assert.equal(mapped.resources[0].installMappings.length, 2);
for (const role of ['staff', 'management']) {
const mapping = approvedMapping(mapped.resources[0], role);
assert.ok(mapping); assert.equal(mapping.destinationInstalled, false);
assert.equal(mapping.destinationAccessVerified, false);
}
});
test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => { test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-')); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
try { try {
@@ -119,21 +211,26 @@ test('private collector validates binary, reuses hash, rejects errors and unappr
} finally { await fs.rm(dir, { recursive: true }); } } 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 { readPortalInput } = await import('../src/utils/portal-input.mjs');
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-')); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-'));
try { try {
const pass = randomBytes(24).toString('base64'); const pass = randomBytes(24).toString('base64');
const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '<p>Synthetic</p>', pass))); 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 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)); assert.ok(readPortalInput(options));
// Form modes cannot substitute for the independent private-input approval. // Form modes cannot substitute for the independent private-input approval.
for (const mode of ['live', 'off', 'mock']) { for (const mode of ['live', 'off', 'mock']) {
assert.ok(readPortalInput({ ...options, mode })); 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); for (const changed of [{ localApproved: undefined }, { localApproved: 'false' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), 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 }); } } finally { await fs.rm(dir, { recursive: true }); }
}); });