Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab08131d6
|
No files matched your search
@@ -39,7 +39,7 @@
|
||||
packages = developmentPackages ++ [ pkgs.playwright-driver ];
|
||||
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
|
||||
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
|
||||
withFirefox = true;
|
||||
withFirefox = false;
|
||||
withWebkit = false;
|
||||
withFfmpeg = false;
|
||||
};
|
||||
|
||||
+1
-4
@@ -10,10 +10,7 @@ PUBLIC_AIA_FORMS_MOCK_RESULT=success
|
||||
PUBLIC_AIA_FORM_A_PREVIEW=false
|
||||
PUBLIC_ALLOW_INDEXING=false
|
||||
|
||||
# Employee portal ciphertext is a non-PUBLIC build-only input.
|
||||
# Employee portal ciphertext is an opt-in local compatibility preview only.
|
||||
# Never supply a passphrase through environment variables or build arguments.
|
||||
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false
|
||||
# AIA_PORTAL_CIPHERTEXT_FILE=/owner-controlled/private/ciphertext.json
|
||||
# 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
|
||||
+3
-43
@@ -83,8 +83,6 @@ The default values are:
|
||||
|
||||
```txt
|
||||
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
|
||||
PUBLIC_COUNTERS_MODE=off
|
||||
PUBLIC_COUNTERS_LIVE_APPROVED=false
|
||||
PUBLIC_ALLOW_INDEXING=false
|
||||
```
|
||||
|
||||
@@ -96,16 +94,6 @@ not deploy anything or change environment files. Run `npm run build` afterward
|
||||
to restore staging output. Only explicitly authorized production deployments
|
||||
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
|
||||
|
||||
### Development Server
|
||||
@@ -592,15 +580,6 @@ npm run audit:links
|
||||
|
||||
The live crawl is optional and may fail in restricted network environments.
|
||||
|
||||
## Referral CTA
|
||||
|
||||
The public `/referrals` page preserves the existing site's **Refer a Client**
|
||||
button, linking directly to https://form.jotform.com/231638510219149. The
|
||||
site does **not** embed a referral form or require Form C. This destination is
|
||||
separate from Form A's 16 allowlisted placements. Legacy-hosted-form privacy,
|
||||
recipient, accessibility and operational acceptance remain the owner's release
|
||||
responsibility.
|
||||
|
||||
## Forms
|
||||
|
||||
As of the owner decision on 2026-10-30, `npm run dev` and `npm run build`
|
||||
@@ -612,35 +591,16 @@ 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
|
||||
mode means live; obsolete `isolated-test` and other invalid values produce an
|
||||
explicit configuration error. Remove old approval/environment variables from
|
||||
local settings. Indexing and form mode remain independent of counter mode.
|
||||
local settings. Indexing is independent of forms; counters remain off by default.
|
||||
|
||||
See the [current Form A guide](integrations/jotform/README.md) for the exact
|
||||
registry, safeguards, rollback and owner acceptance requirements. Form C remains
|
||||
inactive. Employee-portal private input retains its own separate gates.
|
||||
|
||||
Verify matching output with
|
||||
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live --counters=off`;
|
||||
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live`;
|
||||
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
|
||||
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.
|
||||
for those explicit builds. No actual submissions run in automated tests.
|
||||
|
||||
### Google Ads consultation-form conversion handoff
|
||||
|
||||
|
||||
@@ -1,118 +1,77 @@
|
||||
# Employee portal compatibility and private migration
|
||||
|
||||
Recorded 2026-11-04. The clinical director accepted the collected, non-PHI
|
||||
staff and management portal content and the interim shared-code presentation.
|
||||
Browser-side decryption remains access friction rather than individual
|
||||
authentication, revocation, a secure session, or a HIPAA determination.
|
||||
|
||||
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.
|
||||
Recorded 2026-10-30. Browser-side shared-code decryption is approved only for
|
||||
local compatibility development. It is not authentication, individual revocation,
|
||||
a secure session or a HIPAA compliance determination. Resources need separate
|
||||
server authorization before cutover.
|
||||
|
||||
## Source and private collection
|
||||
|
||||
The 42 approved non-PHI resources have already been collected under the ignored
|
||||
`private/employee-portal/hubfs/` workspace. Collection and clinical review are
|
||||
complete. Do not run a new collection pass for production preparation.
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Review the private mapping summary. The role rule maps staff-only references to
|
||||
`/staff-resources/`, management-only references to `/management-resources/`, and
|
||||
duplicates a shared source into both protected namespaces. After the owner
|
||||
approves that operational policy, run:
|
||||
|
||||
```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.
|
||||
The original stays outside Git as rollback evidence. No access code was available
|
||||
to the agent. The exact resource count and real content compatibility remain
|
||||
unverified. Do not infer the earlier approximate 43 links as a measured count.
|
||||
|
||||
From `www`, in the owner's private interactive terminal:
|
||||
|
||||
```sh
|
||||
node tools/employee-portal-migrate.mjs collect \
|
||||
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
|
||||
```
|
||||
|
||||
Enter each legitimate view code at its hidden prompt. Never place codes in
|
||||
arguments, environment variables, shell history or reports. The utility checks
|
||||
both streams, keeps plaintext in memory, and collects only referenced `/hubfs/`
|
||||
resources from the verified source origin. Other origins require explicit
|
||||
review/code allowlisting; redirects are bounded and never inherit credentials.
|
||||
No Jotform, Google or other vendor forms are fetched.
|
||||
|
||||
Private output is `private/employee-portal/`: directories 0700, manifests and
|
||||
binary resources 0600. This exact subtree is gitignored and outside Astro's
|
||||
public directory. Filenames are SHA-256 hashes of source URLs, independently
|
||||
verified by content hashes. The manifest retains original references, labels,
|
||||
view membership, MIME, bytes, status and delivery/classification decisions.
|
||||
The manifest preserves the original source URL, collection hash, size, reference
|
||||
membership and accepted source evidence. Public legacy reachability did not
|
||||
infer public approval.
|
||||
All new resources default to UNRESOLVED. Publicly reachable does not mean public
|
||||
approved. Resuming verifies an existing file's content hash; mismatches are not
|
||||
overwritten. HTML error documents, executable responses, invalid known signatures,
|
||||
redirects outside approved destinations and resources over 50 MiB are rejected.
|
||||
The first pass allows only the verified site origin. If a legitimate `/hubfs/`
|
||||
response redirects to a HubSpot CDN, inspect that exact HTTPS origin first, then
|
||||
add its origin to the private manifest's `approvedOrigins` list and rerun. Never
|
||||
approve a redirect based only on a failed download or a broadly shared vendor
|
||||
domain. Failures remain private review items. The utility prints counts only.
|
||||
|
||||
Before every artifact transfer, verify that only `dist` is copied and that it
|
||||
contains no private manifest, document or real ciphertext unless that ciphertext
|
||||
has separately been authorized for the local preview. Never archive the whole
|
||||
working directory as a deployable artifact.
|
||||
|
||||
## Historical collection and rewriting recovery notes
|
||||
## Approved rewriting
|
||||
|
||||
The original source, collection manifests, collected bytes and prior ciphertext
|
||||
revisions remain recovery evidence. Do not change their classifications or
|
||||
destination flags by hand; use the production-preparation sequence above so
|
||||
stream identity, references, source hashes and protected destinations remain
|
||||
consistent. CSS/srcset dependencies remain an explicit error rather than a
|
||||
silent rewrite.
|
||||
The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL,
|
||||
MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`,
|
||||
`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for
|
||||
internal documents) `destinationAccessControlled` only with actual evidence.
|
||||
The utility does not create those destinations or infer their approval.
|
||||
|
||||
```sh
|
||||
node tools/employee-portal-migrate.mjs rewrite \
|
||||
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
|
||||
```
|
||||
|
||||
It reauthenticates both views, changes approved quoted href/src attributes only,
|
||||
preserves unrelated HTML/labels, and re-encrypts with fresh salts and IVs. It
|
||||
verifies round-trip decryption and distinct codes before writing new ciphertext
|
||||
with exclusive filenames. The original source is untouched. CSS/srcset mappings
|
||||
require separate review and are rejected rather than silently rewritten.
|
||||
Unresolved links retain their original destination as a recorded dependency,
|
||||
not a claim of verified destination health. A changed manifest alone never
|
||||
changes links in the encrypted stream.
|
||||
|
||||
## Local preview and content safety
|
||||
|
||||
Use a retained private ciphertext revision:
|
||||
Use a private ciphertext file generated by `collect` or `rewrite`:
|
||||
|
||||
```sh
|
||||
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \
|
||||
|
||||
@@ -57,11 +57,8 @@ Every URL has exactly `formContext` and `language` parameters:
|
||||
| consultation | `/client-consultation` (en), `/es/client-consultation` (es), `/schedule-consultation` (en) | Immediate, 3 |
|
||||
| consultation | `/aba-therapy`, `/autism-evaluations`, `/learner-social-club` (en), and their `/es/` counterparts (es) | Deferred, 6 |
|
||||
|
||||
The context is presentation-only. Physical Form B is retired. No Form C is planned.
|
||||
`/referrals` independently links to the existing hosted Jotform
|
||||
`https://form.jotform.com/231638510219149`, not to Form A. That link is
|
||||
owner-directed but its legacy form's runtime privacy/recipient acceptance remains
|
||||
separate from Form A. Other routes cannot acquire Form A by setting a mode.
|
||||
The context is presentation-only. Physical Form B is retired. Form C and
|
||||
`/referrals` remain inactive; other routes cannot acquire Form A by setting a mode.
|
||||
The typed future Academy service copy does not publish another page.
|
||||
|
||||
Live service drawers open at four seconds OR 50% scroll, canceling the unused
|
||||
@@ -113,15 +110,6 @@ node tools/phase10-artifact-verify.mjs --mode=indexing --forms=live
|
||||
Employee-portal ciphertext remains separately gated by its own approval, private
|
||||
input and non-indexing preview requirement. Form A modes cannot authorize it.
|
||||
|
||||
## Existing professional referral destination (owner decision 2026-11-01)
|
||||
|
||||
`/referrals` has one `Refer a Client` link to
|
||||
[the existing hosted Jotform](https://form.jotform.com/231638510219149). It does not embed Form A
|
||||
or use Form C. No new form, question, Jotform integration, query parameter or
|
||||
notification routing is introduced. Test the link and verify the separate
|
||||
existing form's privacy, field necessity, recipients and confirmation with its
|
||||
account owner before release.
|
||||
|
||||
## Form context and button labels
|
||||
|
||||
The current Form A design uses a hidden `formContext` value with fixed, approved values such as `contact` and `landing`. Jotform does not provide a native conditional rule that changes one Submit button's accessible label safely in every context. If owner-approved context-specific labels are required, use multiple native Jotform Submit buttons and Jotform's conditional visibility rules:
|
||||
@@ -142,7 +130,7 @@ cross-origin iframe.
|
||||
|
||||
- [x] Direct English/Spanish Form A language handling, conditional email requirement, synthetic submission and native thank-you were owner-verified; this is not embedded-iframe acceptance.
|
||||
- [x] Direct staff-notification/autoresponder delivery, generic no-answer/no-PDF content, fixed Reply-To, recipient access/retention, and absent webhooks/integrations were owner-verified for Form A.
|
||||
- [ ] Exact remaining Form A consultation question properties, choices, conditional routing and availability, and consultation language/configuration, are approved.
|
||||
- [ ] Exact remaining Form A consultation question properties, choices, conditional routing and availability, plus future Form C configuration, are approved.
|
||||
- [ ] English and Spanish embedded-iframe labels, validation, confirmation, and button variants are reviewed by fluent-language owners.
|
||||
- [ ] The hidden honeypot remains present, effective, and excluded from user-facing visual changes.
|
||||
- [ ] Exactly one native Submit button is visible for every valid context.
|
||||
|
||||
@@ -63,10 +63,10 @@ Test direct requests, alternate encodings, role crossover and rollback.
|
||||
- Portal HTML: shared-code browser decryption, offline-guessable access friction.
|
||||
- Documents: require server authorization even if URLs are encrypted in the UI.
|
||||
|
||||
The approved production portal build uses a non-public encrypted two-stream
|
||||
payload and retains the same browser-side decryption boundary. Approved non-PHI
|
||||
documents install separately into the VPS-owned staff/management directories;
|
||||
they are never copied into `dist`. Verify both protected namespaces, two-view
|
||||
separation, artifact exclusions, code rotation, restore and rollback before
|
||||
cutover. Revoking a static release cannot retract ciphertext or files already
|
||||
downloaded by visitors. Site cutover remains separately authorized.
|
||||
The compatibility preview cannot grant production portal access. Before cutover,
|
||||
classify every document, implement and verify its delivery destination, rewrite
|
||||
and re-encrypt links, test two-view separation, rotate shared codes privately,
|
||||
check the public artifact for private files, and obtain owner/security approval.
|
||||
Back up encrypted configuration and documents separately from keys; rehearse
|
||||
restore and rollback. Revoking a static release cannot retract ciphertext or
|
||||
files already downloaded by visitors. Site cutover is still unauthorized.
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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.
|
||||
@@ -10,10 +10,6 @@
|
||||
"check": "astro check",
|
||||
"build": "astro check && astro build",
|
||||
"build:sandbox": "NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro check && NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro build",
|
||||
"deploy": "node tools/deploy.mjs",
|
||||
"deploy:rollback": "node tools/deploy-rollback.mjs",
|
||||
"deploy:portal-resources": "node tools/deploy-portal-resources.mjs",
|
||||
"portal:prepare": "node tools/employee-portal-migrate.mjs",
|
||||
"preview": "astro preview",
|
||||
"extract:blog-footers": "node tools/extract-blog-footers.mjs",
|
||||
"crawl:live": "node tools/crawl-live-site.mjs",
|
||||
@@ -27,8 +23,6 @@
|
||||
"test:seo": "node --experimental-strip-types --test tools/seo.test.mjs",
|
||||
"test:library": "node --experimental-strip-types --test tools/library.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",
|
||||
"generate:sitemap": "node tools/generate-sitemap.mjs",
|
||||
"generate:redirects": "node tools/generate-redirects.mjs"
|
||||
|
||||
@@ -1,61 +1,6 @@
|
||||
# AIA Astro Migration — Living Session Handoff
|
||||
|
||||
## 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)
|
||||
**Last updated:** October 30, 2026 (UTC)
|
||||
**Repository:** `AIAMarketing/azinstitute4autism.com`
|
||||
**Working branch:** `faithful-astro-migration`
|
||||
**Last pushed checkpoint:** `55adb797051473c3067a0d454515153237e9b8d6` — completion handling and portal styling. The new commit containing this handoff simplifies Form A to default-live without rewriting that history.
|
||||
@@ -81,7 +26,7 @@ and form environment role are retired, not renamed.
|
||||
Indexing and forms are independent. Both publication modes emit ten immediate
|
||||
frames (two contact, five landing, three consultation) and six deferred bilingual
|
||||
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
|
||||
origin, context and language. Form B is retired; Form C is canceled by the November 1 referral decision.
|
||||
origin, context and language. Forms B/C are not activated; physical B is retired.
|
||||
|
||||
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
|
||||
manual CTA override, deferred loading, verified completion acknowledgment for
|
||||
@@ -268,10 +213,10 @@ trusted-source comparison. No insecure retrieval was repeated.
|
||||
|
||||
### Current two-form status — 2026-10-29
|
||||
|
||||
- Form A uses one 16-placement live registry: two contact, five landing, three standalone consultation and six EN/ES service drawers. Mode defaults to live; off/mock are explicit options. `/referrals` independently links to the owner-approved existing hosted Jotform; Form C creation is canceled. See the November 1 decision above.
|
||||
- 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.
|
||||
- Owner-confirmed direct-Jotform evidence covers synthetic EN/ES submission, native thank-you, conditional email/language, generic no-answer/no-PDF staff notification, autoresponder delivery, corrected fixed Reply-To, no webhooks/integrations, privacy scope, recipient access and retention. The owner also reports successful Astro-iframe synthetic submission and confirmation on English contact, Spanish contact and one ABA landing placement; record the exact third route before release. Functional submission acceptance is complete only for those owner-reported samples. All-sixteen-route accessibility, runtime-network/telemetry, deployment and site-release acceptance remain open.
|
||||
- The adapter retains fixed URLs, responsive fixed heights, `no-referrer`, `allow-forms allow-scripts allow-same-origin`, no helper script, and a hosted fallback that is hidden without space until a 15-second missing-load timeout. A frame load only clears that timeout; it does not prove cross-origin application health or accepted submission. The official Jotform resizing handler is conditionally acceptable pending separately authorized source, origin, privilege, Firefox and error-state verification; it is not implemented. Fixed heights remain current.
|
||||
- No additional Form A approval/environment flags remain. Indexing does not authorize or disable forms. Marketing-only notification routing remains a pre-cutover operational check. Rollback requires explicit mode `off`, rebuild and artifact verification; merely removing the mode restores live. No Form C is needed. 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. Form C, counters, analytics and API forwarding remain inactive.
|
||||
|
||||
The indexing-enabled build has 127 HTML routes/canonicals and 109 sitemap URLs.
|
||||
Normal staging has 128 `noindex,nofollow` pages because it also contains `/lp`;
|
||||
|
||||
@@ -4367,40 +4367,3 @@ because credentials were unavailable. No private markup or resources are tracked
|
||||
The Spanish extra dropdown choices remain a Jotform Builder review item, with
|
||||
fixed Astro context/locale URLs verified. See
|
||||
[focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md).
|
||||
|
||||
## 2026-11-01 — Tour map and CTA restoration
|
||||
|
||||
Compared the public `/tour` HTML retrieved with normal TLS validation on
|
||||
2026-11-01 against the migrated page. The source ends with a centered filled
|
||||
“Schedule a Tour Today” link to `/client-consultation`, followed by a full-width
|
||||
360px Leaflet 1.9.4 map. Restored the CTA using the existing AIA `Button`
|
||||
component and added a Tour-only map below the content container.
|
||||
|
||||
The map preserves the source view `[33.63, -111.88756]`, zoom 12, clinic marker
|
||||
`[33.617746, -111.88756]`, OpenStreetMap attribution, 124px logo and directions
|
||||
popup. Leaflet CSS/JS use the same version and SRI hashes already used by Contact;
|
||||
tile requests go to `tile.openstreetmap.org`. These are external map dependencies,
|
||||
not self-hosted assets. No analytics, forms, clinical copy, metadata or routes
|
||||
changed. The logo is local. Intentional accessibility/reliability improvements:
|
||||
meaningful region/marker labels, visible link focus, safe new-tab links, correctly
|
||||
encoded directions destination and address/directions fallback when JavaScript
|
||||
or Leaflet is unavailable. Existing nine-photo gallery and captions are unchanged.
|
||||
|
||||
Validation: Astro check/build passed (128 pages); link audit found zero broken
|
||||
internal links; five Phase 8 accessibility tests and the focused nine-photo Tour
|
||||
regression passed. Chromium comparison against the retrieved live HTML and the
|
||||
local build at 390px and 1440px passed: both maps span the viewport at 360px
|
||||
height, all 4/12 requested map tiles loaded, marker/directions popup rendered,
|
||||
and the Astro page had no horizontal overflow or JavaScript exceptions. The
|
||||
CTA matches source fill/border `#ffbd59`, text `#254080` and no underline, using
|
||||
the established Astro button proportions rather than copying HubSpot CSS.
|
||||
No-JavaScript address/directions and consultation-link fallback passed.
|
||||
|
||||
Evidence and full-page/map screenshots: `/tmp/aia-tour-review-hknOnA/`.
|
||||
Read-only map/static asset GETs were allowed; unrelated vendor scripts and
|
||||
tracking requests were blocked. Consequently the live reference reported missing
|
||||
jQuery errors, while the Astro page reported none; this was a bounded map/CTA
|
||||
comparison, not validation of all live HubSpot functionality. An initial test
|
||||
selector matched both directions and the popup-close link; narrowing it to the
|
||||
external directions link resolved the test-only failure. No account changes,
|
||||
submissions, deployment, staging or commit occurred.
|
||||
@@ -12,60 +12,6 @@ location / {
|
||||
try_files $uri $uri/index.html $uri.html =404;
|
||||
}
|
||||
|
||||
## HubSpot redirect export imported from repository-root `url-redirects.csv`
|
||||
## on 2026-11-01. The export contains 45 STANDARD 301 rules. The three aliases
|
||||
## already generated below (`/aba`, `/autismevaluations`, and
|
||||
## `/learnersocialclub`) are intentionally not duplicated here.
|
||||
##
|
||||
## The IndexNow verification-file redirect retains HubSpot's original external
|
||||
## target. Before cutover, confirm that target remains reachable after this
|
||||
## hostname points at nginx; serving the required verification file locally may
|
||||
## be necessary instead.
|
||||
rewrite ^/abaportal$ /employee-portal permanent;
|
||||
rewrite ^/aiaportal$ /employee-portal permanent;
|
||||
rewrite ^/giveback$ /donate-autism-giveback permanent;
|
||||
rewrite ^/blog$ /library permanent;
|
||||
rewrite ^/donate$ /donate-autism-giveback permanent;
|
||||
rewrite ^/contactus$ /contact permanent;
|
||||
rewrite ^/post/4waysonhowtoreducebehaviormanagement$ /library/reduce-behavior-management permanent;
|
||||
rewrite ^/post/playandleisureskillsforchildrenwithautism$ /library/play-leisure-skills permanent;
|
||||
rewrite ^/post/managing-aggressive-behavior-outbursts-and-proper-communication-with-your-child-during-those-moments$ /library/managing-aggressive-behavior-outbursts permanent;
|
||||
rewrite ^/post/critical-thinking-skills-for-children-with-autism$ /library/critical-thinking-skills permanent;
|
||||
rewrite ^/post/new-year-goals-for-our-children-with-autism$ /library/new-year-goals-2023 permanent;
|
||||
rewrite ^/post/aia-world-autism-month-celebration$ /library/aia-world-autism-community-day-celebration permanent;
|
||||
rewrite ^/forms$ /client-forms permanent;
|
||||
rewrite ^/post/november-aia-update$ /library/november-2022-aia-update permanent;
|
||||
rewrite ^/post/behavior-modification-techniques-reinforcement-theory$ /library/behavior-modification-techniques-reinforcement-theory permanent;
|
||||
rewrite ^/post/navigating-the-autism-journey-understanding-and-supporting-our-children-with-autism$ /library/understanding-autism-support permanent;
|
||||
rewrite ^/post/arizona-institute-for-autism-is-thrilled-to-announce-its-transformation-into-an-integrated-aba-model$ /library/integrated-aba-model-announcement permanent;
|
||||
rewrite ^/post/repetitive-behaviors-with-children-diagnosed-with-autism$ /library/repetitive-behavior permanent;
|
||||
rewrite ^/post/asd-sensory-friendly-thanksgiving-recipe$ /library/asd-sensory-friendly-recipe-thanksgiving-pumpkin-cake permanent;
|
||||
rewrite ^/post/toilet-training-for-a-child-with-autism-using-applied-behavior-analysis-aba$ /library/aba-toilet-training-child-autism permanent;
|
||||
rewrite ^/post/new-year-2024-holiday-and-autism$ /library/new-year-2024-autism-holiday-tips permanent;
|
||||
rewrite ^/post/understanding-sensory-processing-in-the-context-of-aba-therapy$ /library/sensory-processing permanent;
|
||||
rewrite ^/post/enhancing-generalization-for-broader-impact$ /library/enhancing-generalization-skills permanent;
|
||||
rewrite ^/post/asd-sensory-friendly-recipes-pancakes$ /library/asd-sensory-friendly-recipes-pancakes permanent;
|
||||
rewrite ^/post/the-benefits-of-aba-therapy-for-children-with-autism$ /library/aba-therapy-benefits permanent;
|
||||
rewrite ^/post/meet-rula-diab-board-certified-behavior-analyst-bcba$ /library/meet-rula-diab-bcba permanent;
|
||||
rewrite ^/post/integrity-empowerment-and-excellence-arizona-institute-for-autism-expands-to-new-office$ /library/new-aia-scottsdale-office permanent;
|
||||
rewrite ^/post/community-highlights-meet-rula-diab-of-arizona-institute-for-autism$ /library/community-highlight-meet-rula-diab permanent;
|
||||
rewrite ^/post/navigating-dtt-and-net-in-aba-therapy$ /library/aba-therapy-dtt-net-guide permanent;
|
||||
rewrite ^/post/benefits-of-prompting-and-parenting-in-aba-therapy$ /library/aba-therapy-prompting-parenting-benefits permanent;
|
||||
rewrite ^/post/building-rapport-with-a-child-in-aba-therapy-the-key-to-fun-engaging-and-effective-sessions$ /library/autism-therapy-rapport-strategies permanent;
|
||||
rewrite ^/post/exploring-behavior-management-and-understanding-the-functions-of-behavior$ /library/behavior-management-functions-guide permanent;
|
||||
rewrite ^/post/navigating-back-to-school-understanding-antecedent-and-consequence-interventions-for-families-with$ /library/aba-school-readiness-guide permanent;
|
||||
rewrite ^/post/proactive-and-reactive-strategies-in-aba$ /library/proactive-reactive-aba-strategies-guide permanent;
|
||||
rewrite ^/post/individualized-aba-therapy$ /library/individualized-aba-therapy permanent;
|
||||
rewrite ^/post/parental-self-care-for-autism-families$ /library/autism-family-self-care-tips permanent;
|
||||
rewrite ^/post/teaching-self-advocacy-through-aba-empowering-children-to-express-their-needs$ /library/autism-self-advocacy-skills-aba permanent;
|
||||
rewrite ^/ar/library/author/rula-diab-ar$ https://www.azinstitute4autism.com/ar/library/author/rula-diab permanent;
|
||||
rewrite ^/locations$ /contact permanent;
|
||||
rewrite ^/schedule/consultation$ /schedule-consultation permanent;
|
||||
rewrite ^/e2ae5722f9ae40e89a9ebb8d53200e66\.txt$ https://www.azinstitute4autism.com/hubfs/e2ae5722f9ae40e89a9ebb8d53200e66.txt permanent;
|
||||
rewrite ^/parent-guide$ /library/parents-guide-to-autism-and-aba permanent;
|
||||
|
||||
## Existing generated migration redirects follow. They include the remaining
|
||||
## three rules from the HubSpot export, plus current page-one aliases.
|
||||
rewrite ^/aba$ /aba-therapy permanent;
|
||||
rewrite ^/autismevaluations$ /autism-evaluations permanent;
|
||||
rewrite ^/learnersocialclub$ /learner-social-club permanent;
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
# Phase 10 — Technical Readiness Package
|
||||
|
||||
## Current referral decision — 2026-11-01 (supersedes Form C planning)
|
||||
|
||||
The owner directs `/referrals` to retain the current public-site workflow: one
|
||||
button-styled **Refer a Client** link to
|
||||
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
|
||||
to create, embed, configure or launch. The former Form C / proposed two-form
|
||||
architecture is historical only. `/referrals` renders no local form or Form A
|
||||
iframe; the original 16 approved Form A placements remain unchanged.
|
||||
|
||||
This is authorization to restore the existing destination in Astro, **not**
|
||||
verification of that separate legacy Jotform's HIPAA configuration, notification
|
||||
recipients, patient-data handling, accessibility or continued operational
|
||||
suitability. Review the actual hosted form and its clinical/privacy claims before
|
||||
production cutover. No account modification or submission occurred in this PR.
|
||||
|
||||
|
||||
Date: 2026-10-22 UTC
|
||||
Status: Technical handoff prepared; production gates remain open.
|
||||
|
||||
@@ -33,7 +17,7 @@ and form environment role are retired, not renamed.
|
||||
Indexing and forms are independent. Both publication modes emit ten immediate
|
||||
frames (two contact, five landing, three consultation) and six deferred bilingual
|
||||
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
|
||||
origin, context and language. Form B is retired; Form C creation was canceled by owner decision on 2026-11-01.
|
||||
origin, context and language. Forms B/C are not activated; physical B is retired.
|
||||
|
||||
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
|
||||
manual CTA override, deferred loading, verified completion acknowledgment for
|
||||
@@ -101,7 +85,7 @@ The final normal staging build was restored after the indexing build. The explic
|
||||
1. Record a locked Node/npm/toolchain and reproducible artifact manifest.
|
||||
2. Obtain sanitized Contabo/NixOS/nginx/TLS/DNS/Cloudflare facts.
|
||||
3. Verify host-local serving, canonical host, redirects, 404s, cache headers and rollback on an approved staging host.
|
||||
4. Default-live Form A is owner-authorized on exactly 16 placements, including public development hosting. No approval flags remain; indexing is independent. Final accessibility, Spanish options/provider-event, runtime privacy and marketing-recipient operational review remain open. 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.
|
||||
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.
|
||||
5. Obtain P9-04 counter origin/credential/consent approval before a live adapter.
|
||||
6. Resolve P9-05 analytics/advertising measurement and P9-07 privacy/legal approval.
|
||||
7. Obtain P9-06 clinical/organizational and fluent-language approvals, including Arabic diagrams and Spanish HSA/FSA claims.
|
||||
@@ -111,7 +95,7 @@ The final normal staging build was restored after the indexing build. The explic
|
||||
|
||||
## Decision status
|
||||
|
||||
P9-01 through P9-10 remain **Pending decision**. No technical feasibility result in this package is an approval. Default-live Form A is explicitly owner-authorized. Counters, analytics, advertising, The existing referral destination, private portal serving and hosting activation still require their respective acceptance; no Form C creation is planned.
|
||||
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.
|
||||
|
||||
## Recommended checkpoint boundaries
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# Phase 9 — Decision register
|
||||
|
||||
> **2026-11-01 referral decision:** The `/referrals` CTA now uses the existing
|
||||
> hosted Jotform at https://form.jotform.com/231638510219149. Do not create Form C.
|
||||
> P9-03 remains open for review of that legacy destination's actual privacy,
|
||||
> notification/recipient, clinical and operational configuration. Older Form C
|
||||
> references below are historical and superseded.
|
||||
>
|
||||
|
||||
Recorded: 2026-10-14 (source review began October 13). Prepared during Phase 7; no decision or integration is approved by this register. Owner roles are proposed responsibilities, not assigned individuals. Every item is **Pending decision**. A technical option is not approval; translation is not legal/clinical sign-off; browser acceptance is not deployment authorization.
|
||||
|
||||
## P9-01 — Landing-page ownership
|
||||
@@ -42,14 +35,14 @@ Recorded: 2026-10-14 (source review began October 13). Prepared during Phase 7;
|
||||
## P9-03 — Form/API ownership
|
||||
|
||||
- **Status:** Pending decision
|
||||
- **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.
|
||||
- **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.
|
||||
- **Evidence:** [two-form integration specification](phase-9-two-form-integration-specification-2026-10-28.md), [../src/components/FormShell.astro](../src/components/FormShell.astro)
|
||||
- **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.
|
||||
- **Options:** Approved direct Jotform Form A consultation and separate Form C; no Contabo backend for protected lead data.
|
||||
- **Risk if unresolved:** Lost inquiries, accidental collection, unsafe retention or ambiguous failure.
|
||||
- **Decision owner role:** AIA intake/operations owner
|
||||
- **Required reviewers:** Privacy/legal/security
|
||||
- **Dependencies:** Field necessity, recipient, spam controls, retention/access, failure handling, P9-07/09
|
||||
- **Acceptance criteria:** 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.
|
||||
- **Acceptance criteria:** Synthetic nonproduction submission success/failure, no-JS/Enter behavior, access and retention documented; explicit activation approval.
|
||||
- **Impacted routes/systems:** Contact, consultation, referrals, service drawer
|
||||
- **Authorization required:** Explicit owner decision, then a bounded implementation/test scope; live activation/deployment requires separate authorization.
|
||||
- **Resolution date/evidence:** None; not approved.
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
# P9-03 source-specific implementation plan
|
||||
|
||||
## Current referral decision — 2026-11-01 (supersedes Form C planning)
|
||||
|
||||
The owner directs `/referrals` to retain the current public-site workflow: one
|
||||
button-styled **Refer a Client** link to
|
||||
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
|
||||
to create, embed, configure or launch. The former Form C / proposed two-form
|
||||
architecture is historical only. `/referrals` renders no local form or Form A
|
||||
iframe; the original 16 approved Form A placements remain unchanged.
|
||||
|
||||
This is authorization to restore the existing destination in Astro, **not**
|
||||
verification of that separate legacy Jotform's HIPAA configuration, notification
|
||||
recipients, patient-data handling, accessibility or continued operational
|
||||
suitability. Review the actual hosted form and its clinical/privacy claims before
|
||||
production cutover. No account modification or submission occurred in this PR.
|
||||
|
||||
|
||||
## Current decision — 2026-10-30: default-live Form A
|
||||
|
||||
The owner intentionally supersedes the earlier isolated-test and seven-route
|
||||
@@ -30,7 +14,7 @@ and form environment role are retired, not renamed.
|
||||
Indexing and forms are independent. Both publication modes emit ten immediate
|
||||
frames (two contact, five landing, three consultation) and six deferred bilingual
|
||||
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
|
||||
origin, context and language. Form B is retired; Form C planning is canceled as of 2026-11-01.
|
||||
origin, context and language. Forms B/C are not activated; physical B is retired.
|
||||
|
||||
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
|
||||
manual CTA override, deferred loading, verified completion acknowledgment for
|
||||
@@ -48,7 +32,7 @@ telemetry/privacy, recipient-routing or hosting acceptance. See the
|
||||
Earlier dated decisions and test results below are historical evidence, not
|
||||
current environment setup instructions.
|
||||
|
||||
Date: **2026-10-18** · Plan version **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).
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -75,7 +59,7 @@ The confirmation is limited. Recipient access and Form A retention/deletion cont
|
||||
| F-003 | `/client-consultation` | `en/client-consultation.md` | `FormShell.astro` via PageLayout | Form A / `consultation` EN, isolated-test only |
|
||||
| F-004 | `/es/client-consultation` | `es/client-consultation.md` | Same | Form A / `consultation` ES, isolated-test only |
|
||||
| F-005 | `/schedule-consultation` | `en/schedule-consultation.mdx` | Same | Form A / `consultation` EN, isolated-test only |
|
||||
| F-006 | `/referrals` | `en/referrals.mdx` | Preserve the existing `Refer a Client` button, directly link https://form.jotform.com/231638510219149; no inline form | Existing hosted Jotform; no Form C |
|
||||
| F-006 | `/referrals` | `en/referrals.mdx` | Same; preserve `#form-title` | C EN |
|
||||
| F-007 | `/aba-therapy` | `en/aba-therapy.mdx` | `ServiceInquiryDrawer.astro` | Form A / planned `consultation`; drawer redesign |
|
||||
| F-008 | `/autism-evaluations` | `en/autism-evaluations.mdx` | Same | Form A / planned `consultation`; referral-assistance approval |
|
||||
| F-009 | `/learner-social-club` | `en/learner-social-club.mdx` | Same | Form A / planned `consultation`; program approval |
|
||||
@@ -172,7 +156,7 @@ The owner separately reports that the existing `AIA Lead Generator | plain` form
|
||||
- **Acceptance:** distinguish static elements from fields; every proposed field mapped or explicitly pending; no guessed internal IDs or locale behavior. Required callback/contact expectations approved.
|
||||
- **Stop:** before any form creation/editing, tests generating notifications, or production mapping.
|
||||
|
||||
### Historical Batch B — Configure Form A consultation and create Form C (Form C canceled)
|
||||
### Batch B — Configure Form A consultation and create Form C
|
||||
|
||||
- **Objective:** configure Form A’s future `consultation` context without changing approved `contact`/`landing` behavior, and create one disabled blank Form C — Professional Referral Initiation.
|
||||
- **Authorization:** explicit Jotform mutation permission naming the Form A context, Form C, account owner, reviewers, environment, isolation steps, proposed settings and stopping conditions. The historical `250727122848156` form is not a source and must not be cloned or repurposed.
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
# P9-03 Jotform integration contract
|
||||
|
||||
## Current referral decision — 2026-11-01 (supersedes Form C planning)
|
||||
|
||||
The owner directs `/referrals` to retain the current public-site workflow: one
|
||||
button-styled **Refer a Client** link to
|
||||
[the existing hosted Jotform](https://form.jotform.com/231638510219149). There is **no separate Form C**
|
||||
to create, embed, configure or launch. The former Form C / proposed two-form
|
||||
architecture is historical only. `/referrals` renders no local form or Form A
|
||||
iframe; the original 16 approved Form A placements remain unchanged.
|
||||
|
||||
This is authorization to restore the existing destination in Astro, **not**
|
||||
verification of that separate legacy Jotform's HIPAA configuration, notification
|
||||
recipients, patient-data handling, accessibility or continued operational
|
||||
suitability. Review the actual hosted form and its clinical/privacy claims before
|
||||
production cutover. No account modification or submission occurred in this PR.
|
||||
|
||||
|
||||
## Current decision — 2026-10-30: default-live Form A
|
||||
|
||||
The owner intentionally supersedes the earlier isolated-test and seven-route
|
||||
@@ -30,7 +14,7 @@ and form environment role are retired, not renamed.
|
||||
Indexing and forms are independent. Both publication modes emit ten immediate
|
||||
frames (two contact, five landing, three consultation) and six deferred bilingual
|
||||
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
|
||||
origin, context and language. Form B is retired and no Form C is planned as of 2026-11-01.
|
||||
origin, context and language. Forms B/C are not activated; physical B is retired.
|
||||
|
||||
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
|
||||
manual CTA override, deferred loading, verified completion acknowledgment for
|
||||
@@ -48,9 +32,9 @@ telemetry/privacy, recipient-routing or hosting acceptance. See the
|
||||
Earlier dated decisions and test results below are historical evidence, not
|
||||
current environment setup instructions.
|
||||
|
||||
**Contract ID:** P9-03-JF-2026-10-18 · **Version:** 2.0 · **Decision revision:** 2026-11-01
|
||||
**Contract ID:** P9-03-JF-2026-10-18 · **Version:** 1.9 · **Decision revision:** 2026-10-30
|
||||
|
||||
**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.**
|
||||
**Current status: default-live Form A approved on 16 placements; site deployment/cutover 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.
|
||||
|
||||
@@ -237,7 +221,7 @@ Cross-checked against F-001–F-009 in the local CSV, current source and eligibl
|
||||
| F-003 | `/client-consultation` | EN | `src/components/FormShell.astro:12`, `layouts/PageLayout.astro:15,82` | Form A / `consultation`, isolated-test only |
|
||||
| F-004 | `/es/client-consultation` | ES | Same, explicit locale | Form A / `consultation`, isolated-test only |
|
||||
| F-005 | `/schedule-consultation` | EN | Same FormShell | Form A / `consultation`, isolated-test only |
|
||||
| F-006 | `/referrals` | EN | External button `Refer a Client` | Existing hosted Jotform https://form.jotform.com/231638510219149; no Form C |
|
||||
| F-006 | `/referrals` | EN | Same FormShell; CTA `#form-title` | C |
|
||||
| F-007 | `/aba-therapy` | EN | `src/components/ServiceInquiryDrawer.astro`, `layouts/PageLayout.astro` | Form A / `consultation`, isolated-test-only explicit drawer |
|
||||
| F-008 | `/autism-evaluations` | EN | Same drawer | Form A / `consultation`, isolated-test-only explicit drawer |
|
||||
| F-009 | `/learner-social-club` | EN | Same drawer | Form A / `consultation`, isolated-test-only explicit drawer |
|
||||
|
||||
@@ -10,7 +10,7 @@ JF-08,Embedding,"Approved HTTPS origin, hosted/iframe URLs, dimensions/mobile, s
|
||||
JF-09,Controlled synthetic acceptance,"Actual EN/ES synthetic submission, notifications, success/failure, redirects and privacy boundary",Later separately authorized isolated form/environment; never real patient data,"Owner-confirmed 2026-10-28 direct-Jotform EN/ES and sampled Astro-iframe synthetic submissions with native thank-you and notification/autoresponder delivery. Functional submission acceptance is complete for the reported samples; exact ABA landing route, all-seven-route runtime/privacy and production-release acceptance remain open; 2026-10-30 supersession: 16 placements are default-live by owner authorization, with remaining all-route runtime/accessibility acceptance separate",Owner-verified — Form A functional submission samples,2026-10-28,Separate batch authorization; no action authorized by this checklist
|
||||
OA-01,Intake/operations,"Minimum fields, required flags/contact preference consistency, follow-up/callback expectations, Tour/fundraising scope",Intake/operations,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
|
||||
OA-02,Clinical leadership,"Eligibility, service/evaluation availability, private-pay Social Club distinction, referral boundary and approved claims",Clinical leadership,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
|
||||
OA-03,Jotform account owner,"JF-01–08 for Form A consultation; 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-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-04,English/Spanish reviewer,"Labels, errors, instructions, confirmations and locale switching; no automatic translation approval",English/Spanish reviewer,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
|
||||
OA-05,Privacy/legal/security,"Protected fields, generic email/headers/recipients, embeds/referrers, vendor flows, access/retention and tracking boundaries",Privacy/legal/security,"Owner-verified 2026-10-27 privacy approval for Form A PHI protection, generic no-PHI notification, no PDF attachment, no webhooks/integrations, recipient access and retention/deletion; embed/referrer runtime review and tracking boundaries remain open","Partially verified — Form A configuration, access and retention scope",2026-10-27,Separate batch authorization; no action authorized by this checklist
|
||||
OA-06,Marketing,Deferred attribution categories and redesigned successful-submission semantics,Marketing,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
|
||||
@@ -18,5 +18,5 @@ OA-07,Hosting owner,"Static/API separation, compile/deploy gates, CSP/referrer/l
|
||||
OA-08,Release owner,"Phase 10 integration activation, go/no-go, artifact/vendor rollback and deployment",Release owner,Dated owner approval with scope and sanitized supporting evidence,Pending/Unverified,,Separate batch authorization; no action authorized by this checklist
|
||||
FA-PRD-01,Form A mode and placement contract,Superseded 2026-10-30: live is default on 16 fixed placements; explicit off/mock isolate forms; indexing is independent; invalid modes fail clearly,Release + hosting owner,Owner authorizes public development forms; repository registry/artifact/browser tests; no hosting deployment performed,Owner-authorized architecture — runtime/site-release acceptance separate,2026-10-30,No additional Form A build approval flags; site cutover requires separate authorization
|
||||
FA-CONS-01,Form A consultation context,"Verify fixed `consultation` presentation value, required service-interest question, five approved choices, conditional email/phone behavior, EN/ES translations and no additional collection; no diagnostic-evaluation explanatory note",Account + operations + clinical + privacy owners,Owner reports connected-app configuration; sanitized Builder configuration and approved synthetic test evidence still required; no ID/condition inferred by repository,Owner-reported configured — browser acceptance pending,2026-10-29,Default-live consultation placements owner-authorized 2026-10-30; Jotform runtime/language acceptance and site cutover remain separate
|
||||
FC-01,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
|
||||
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
|
||||
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,60 +0,0 @@
|
||||
# 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,20 +1,4 @@
|
||||
# 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.
|
||||
|
||||
# Phase 9 — Two-form integration specification
|
||||
|
||||
## Current decision — 2026-10-30: default-live Form A
|
||||
|
||||
@@ -30,7 +14,7 @@ and form environment role are retired, not renamed.
|
||||
Indexing and forms are independent. Both publication modes emit ten immediate
|
||||
frames (two contact, five landing, three consultation) and six deferred bilingual
|
||||
service-drawer frames. The bounded registry fixes ID `262946078314059`, HTTPS
|
||||
origin, context and language. Physical Form B is retired; the Form C proposal was canceled on 2026-11-01.
|
||||
origin, context and language. Forms B/C are not activated; physical B is retired.
|
||||
|
||||
Live drawers retain four-second/50%-scroll opening, shared 30-day suppression,
|
||||
manual CTA override, deferred loading, verified completion acknowledgment for
|
||||
@@ -50,10 +34,10 @@ current environment setup instructions.
|
||||
|
||||
**Recorded:** 2026-10-28 UTC; revised 2026-10-30 UTC
|
||||
|
||||
**Scope:** Default-live Form A integration; former Form C plan superseded by existing hosted referral link
|
||||
**Scope:** Default-live Form A integration and future Form C referral initiation
|
||||
**Status:** 16 default-live placements owner-authorized; Jotform language/runtime acceptance and site cutover remain separate
|
||||
|
||||
## Decision revision (historical, superseded for Form C)
|
||||
## Decision revision
|
||||
|
||||
The owner has replaced the former three-physical-form plan with two physical Jotform forms:
|
||||
|
||||
@@ -94,10 +78,7 @@ 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.
|
||||
|
||||
## 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 — Professional Referral Initiation
|
||||
|
||||
Form C remains a separate future physical Jotform created from a blank form. Its initial, minimum-necessary specification is:
|
||||
|
||||
@@ -126,7 +107,7 @@ The following nine placements join the original seven in one live registry. Both
|
||||
| `/es/aba-therapy` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
|
||||
| `/es/autism-evaluations` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
|
||||
| `/es/learner-social-club` drawer | ES | Same shared drawer; live automatic/manual drawer | Form A / `consultation` | Spanish Jotform and fluent-language acceptance |
|
||||
| `/referrals` | EN | 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 |
|
||||
| `/referrals` | EN | `FormShell.astro`, `logicalForm=referral`; inactive | Form C / future referral initiation | Create and verify Form C; no patient data in initial form |
|
||||
|
||||
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.
|
||||
|
||||
@@ -140,7 +121,7 @@ The current Tour CTA in `src/content/pages/en/tour.mdx` points to `/client-consu
|
||||
4. Obtain intake/operations, clinical and privacy approval for the field set and approved service options. Obtain fluent English/Spanish review for all added presentation and error text.
|
||||
5. Run separately authorized synthetic Jotform and Astro-iframe acceptance tests with no real patient data. Cover both locales, all service choices, validation/error states, no-JavaScript and keyboard behavior, confirmation, notifications, responsive sizes and direct hosted fallback.
|
||||
6. Record acceptance separately from the owner-authorized default-live 16-route registry. Site deployment/cutover still requires release authorization; no further Form A build flags are required.
|
||||
7. For `/referrals`, confirm the existing hosted Jotform destination, permissions, response handling, patient-data privacy, staff recipients and operational suitability. Do not create Form C.
|
||||
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`.
|
||||
|
||||
## Retained gates
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد'
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
|
||||
.author-byline { color: inherit; text-decoration: none; }
|
||||
.author-byline:hover { text-decoration: underline; }
|
||||
.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; }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formModeForRuntime, resolveFormAIframe, resolveFormMode, resolveMockFor
|
||||
const { lang = 'en', showBackground = false, route = '' } = Astro.props;
|
||||
const text = formMessages(lang);
|
||||
const title = Astro.props.title ?? text.heading;
|
||||
const logicalForm: LogicalForm = Astro.props.logicalForm ?? 'service';
|
||||
const logicalForm: LogicalForm = Astro.props.logicalForm ?? (Astro.props.title === 'Physician & Insurer Referrals' ? 'referral' : 'service');
|
||||
const config = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE);
|
||||
const mode = formModeForRuntime(config);
|
||||
const mockResult = resolveMockFormResult(import.meta.env.PUBLIC_AIA_FORMS_MOCK_RESULT);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
import { articleMessages } from '../utils/article-messages';
|
||||
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
||||
interface Props { slug: string; lang: string; trackView?: boolean }
|
||||
const { slug, lang, trackView = false } = Astro.props;
|
||||
interface Props { slug: string; lang: string }
|
||||
const { slug, lang } = Astro.props;
|
||||
const text = articleMessages(lang);
|
||||
const counterConfig = resolveCounterConfig(
|
||||
import.meta.env.PUBLIC_COUNTERS_MODE,
|
||||
@@ -15,23 +15,21 @@ const mockResult = import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'failed' || i
|
||||
: 'success';
|
||||
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-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}>
|
||||
<div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} 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 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>
|
||||
<button class="like-button" type="button" data-like aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode !== 'mock'}>
|
||||
<button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode === 'off'}>
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : runtimeMode === 'off' ? text.counterUnavailable : ''}</span>
|
||||
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span>
|
||||
</div>
|
||||
<script>
|
||||
import { bindMockForms } from '../scripts/mock-form';
|
||||
import { bindLiveCounters } from '../scripts/likes-views';
|
||||
bindMockForms();
|
||||
void bindLiveCounters();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
import { formMessages } from '../utils/form-messages';
|
||||
|
||||
const { lang = 'en' } = Astro.props;
|
||||
const text = formMessages(lang);
|
||||
const directions = 'https://www.google.com/maps/dir/?api=1&destination=8901%20E%20Raintree%20Dr%20Ste%20160%2C%20Scottsdale%2C%20AZ%2085260';
|
||||
---
|
||||
<section class="openstreetmap-section" aria-label={text.map}>
|
||||
<div id="aia-openstreetmap" class="openstreetmap" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker} data-directions-label={text.directions}></div>
|
||||
<p data-openstreetmap-fallback>
|
||||
Arizona Institute for Autism — 8901 E Raintree Dr, Ste 160, Scottsdale, AZ 85260.
|
||||
<a href={directions} target="_blank" rel="noopener noreferrer">{text.directions}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script is:inline define:vars={{ directions }}>
|
||||
(() => {
|
||||
const element = document.getElementById('aia-openstreetmap');
|
||||
if (!element || !window.L || element.dataset.initialized) return;
|
||||
|
||||
const map = window.L.map(element, { zoomControl: false }).setView([33.63, -111.88756], 12);
|
||||
element.dataset.initialized = 'true';
|
||||
window.L.control.zoom({ zoomInTitle: element.dataset.zoomIn, zoomOutTitle: element.dataset.zoomOut }).addTo(map);
|
||||
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
});
|
||||
tiles.on('tileload', ({ tile }) => {
|
||||
tile.alt = element.dataset.tileAlt;
|
||||
});
|
||||
tiles.addTo(map);
|
||||
window.L.marker([33.617746, -111.88756], {
|
||||
alt: element.dataset.markerLabel,
|
||||
title: element.dataset.markerLabel
|
||||
}).addTo(map).bindPopup(`
|
||||
<span class="openstreetmap__popup">
|
||||
<b>Arizona Institute for Autism</b><br>
|
||||
<img alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94"><br>
|
||||
<a href="${directions}" target="_blank" rel="noopener noreferrer">${element.dataset.directionsLabel}</a>
|
||||
</span>
|
||||
`).openPopup();
|
||||
// Keep address/directions available without JavaScript or if Leaflet is blocked.
|
||||
document.querySelector('[data-openstreetmap-fallback]')?.setAttribute('hidden', '');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.openstreetmap-section { background: white; margin: 0; padding: 0; }
|
||||
.openstreetmap { height: 360px; width: 100%; }
|
||||
.openstreetmap :global(.openstreetmap__popup) { display: block; text-align: center; }
|
||||
.openstreetmap :global(.leaflet-popup-content) { margin: 10px 20px 8px 18px; }
|
||||
.openstreetmap :global(.openstreetmap__popup img) { display: inline-block; margin-bottom: .25rem; }
|
||||
.openstreetmap :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 2px; }
|
||||
[data-openstreetmap-fallback] { margin: 0; padding: 1rem; text-align: center; }
|
||||
[hidden] { display: none; }
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import ContactForm from '../ContactForm.astro';
|
||||
import FormAPreview from '../FormAPreview.astro';
|
||||
import JotformIframe from '../JotformIframe.astro';
|
||||
import OpenStreetMap from '../OpenStreetMap.astro';
|
||||
import { formMessages } from '../../utils/form-messages';
|
||||
import { formAPreviewEnabled, formModeForRuntime, resolveFormAIframe, resolveFormMode } from '../../utils/form-integration';
|
||||
|
||||
interface Props {
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { title, lang } = Astro.props;
|
||||
const text = formMessages(lang);
|
||||
const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE);
|
||||
const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, formModeForRuntime(formConfig));
|
||||
const route = lang === 'es' ? '/es/contact' : '/contact';
|
||||
@@ -34,7 +35,40 @@ const formAIframe = resolveFormAIframe({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OpenStreetMap lang={lang} />
|
||||
<section class="contact-map-section" aria-label={text.map}>
|
||||
<div id="aia-contact-map" class="contact-map" data-tile-alt={text.tile} data-zoom-in={text.zoomIn} data-zoom-out={text.zoomOut} data-marker-label={text.marker}></div>
|
||||
</section>
|
||||
|
||||
<script is:inline src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const mapElement = document.getElementById('aia-contact-map');
|
||||
|
||||
if (!mapElement || !window.L) return;
|
||||
|
||||
const map = window.L.map(mapElement, { zoomControl: false }).setView([33.63, -111.88756], 12);
|
||||
window.L.control.zoom({ zoomInTitle: mapElement.dataset.zoomIn, zoomOutTitle: mapElement.dataset.zoomOut }).addTo(map);
|
||||
const tiles = window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
});
|
||||
|
||||
tiles.on('tileload', (event) => {
|
||||
if (event.tile && !event.tile.alt) {
|
||||
event.tile.alt = mapElement.dataset.tileAlt;
|
||||
}
|
||||
});
|
||||
|
||||
tiles.addTo(map);
|
||||
|
||||
window.L.marker([33.617746, -111.88756], { alt: mapElement.dataset.markerLabel, title: mapElement.dataset.markerLabel }).addTo(map).bindPopup(`
|
||||
<span style="display:block;text-align:center">
|
||||
<b>Arizona Institute for Autism</b><br>
|
||||
<img class="contact-map__logo" alt="Arizona Institute for Autism" src="/assets/images/aia-logo.svg" width="94">
|
||||
</span>
|
||||
`).openPopup();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.contact-page {
|
||||
@@ -72,6 +106,22 @@ const formAIframe = resolveFormAIframe({
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.contact-map-section {
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contact-map {
|
||||
height: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contact-map :global(.contact-map__logo) {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.contact-page {
|
||||
padding-block: 50px;
|
||||
|
||||
@@ -3,11 +3,9 @@ import { readPortalInput } from '../../utils/portal-input.mjs';
|
||||
import Button from '../Button.astro';
|
||||
// Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build.
|
||||
const payload = readPortalInput({
|
||||
localApproved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED,
|
||||
productionApproved: import.meta.env.AIA_PORTAL_PRODUCTION_APPROVED,
|
||||
approved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED,
|
||||
indexing: import.meta.env.PUBLIC_ALLOW_INDEXING,
|
||||
localFile: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
|
||||
productionFile: import.meta.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE,
|
||||
file: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE,
|
||||
});
|
||||
const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null';
|
||||
---
|
||||
@@ -25,7 +23,6 @@ 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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -34,9 +31,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
.employee-portal { padding-block: 3rem; overflow-wrap: anywhere; }
|
||||
.employee-portal__access { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
|
||||
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
|
||||
input:focus-visible { 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; }
|
||||
input:focus-visible, .employee-portal__content:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
[hidden] { display: none; }
|
||||
.employee-portal__content :global(a) { color: var(--color-primary); }
|
||||
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
@@ -68,19 +63,14 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
min-width: 0;
|
||||
}
|
||||
.employee-portal__content :global(ul.portal-template-grid > li p) { margin: 0; }
|
||||
|
||||
/* Staff resource-button lists do not display bullets or numbers. */
|
||||
.employee-portal__content :global(.portal-resource-list) {
|
||||
list-style: none;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.employee-portal__content :global(.portal-resource-list > li) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.employee-portal__content :global(.portal-resource-list > li::marker) {
|
||||
content: none;
|
||||
.employee-portal__content :global(ul.portal-template-grid a.portal-resource-button) {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 2.75rem;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; }
|
||||
|
||||
@@ -5,7 +5,6 @@ displayH1: "Early Autism Intervention Makes a Difference"
|
||||
slug: "lp/early-intervention"
|
||||
lang: "en"
|
||||
canonical: "https://www.azinstitute4autism.com/lp/early-intervention"
|
||||
noindex: true
|
||||
campaign: "early_intervention"
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## Information We Collect
|
||||
|
||||
|
||||
@@ -26,4 +26,4 @@ At the Arizona Institute for Autism, we are here to support your clients reach n
|
||||
|
||||
To provide the highest level of optimal care, we know that it is essential to provide a working partnership with the client’s diagnostic provider, physician, and their insurance company. To facilitate this collaboration, we have built a system of reporting metrics on the child’s treatment plan with supporting progress charts we share with our partners. This information keeps everyone involved up to date on the client’s progress and future needs. If you are an insurance company or diagnostic provider interested in working with us, contact us to learn more about our referral process.
|
||||
|
||||
<Button href="https://form.jotform.com/231638510219149" center>Refer a Client</Button>
|
||||
<Button href="#form-title" center>Refer a Client</Button>
|
||||
@@ -8,7 +8,6 @@ translationKey: "tour"
|
||||
draft: false
|
||||
---
|
||||
import ImageCarousel from '../../../components/ImageCarousel.astro';
|
||||
import Button from '../../../components/Button.astro';
|
||||
import tourGallery from '../../../data/tour-gallery.json';
|
||||
|
||||
## Tour Arizona Institute for Autism
|
||||
@@ -51,6 +50,4 @@ Arizona's Leading Modern Learning Experience for Children Diagnosed with Autism
|
||||
|
||||
- On-Site Clinical Case Supervisor
|
||||
|
||||
<div style={{ textAlign: 'center', marginBlock: '2.5rem' }}>
|
||||
<Button href="/client-consultation">Schedule a Tour Today</Button>
|
||||
</div>
|
||||
[Schedule a Tour Today](/client-consultation)
|
||||
@@ -30,11 +30,10 @@ const { entry } = await getPublication(Astro.url.pathname);
|
||||
: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-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 ul) { list-style: none; padding: 0; }
|
||||
: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; line-height: 1.25; }
|
||||
:global(.library-sidebar li) { border-bottom: 1px solid var(--color-border); padding-block: .65rem; }
|
||||
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; }
|
||||
:global(.blog-list) { display: block; }
|
||||
:global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; }
|
||||
:global(.library-search-results h2) { margin-top: 0; }
|
||||
|
||||
@@ -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>}
|
||||
</header>
|
||||
<div class="prose blog-post-body container"><slot /></div>
|
||||
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} trackView /></div>
|
||||
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} /></div>
|
||||
</article>
|
||||
<BlogPostFooter entryId={entry.id} lang={entry.data.lang} />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
|
||||
.author-byline { color: inherit; text-decoration: none; }
|
||||
.author-byline:hover { text-decoration: underline; }
|
||||
.author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
|
||||
.article-header { padding-block: 4.5rem 0; text-align: left; }
|
||||
|
||||
@@ -7,14 +7,13 @@ import AboutPage from '../components/pages/AboutPage.astro';
|
||||
import ServicesPage from '../components/pages/ServicesPage.astro';
|
||||
import CareersPage from '../components/pages/CareersPage.astro';
|
||||
import ContactPage from '../components/pages/ContactPage.astro';
|
||||
import OpenStreetMap from '../components/OpenStreetMap.astro';
|
||||
import FaqPage from '../components/pages/FaqPage.astro';
|
||||
import InsurancePage from '../components/pages/InsurancePage.astro';
|
||||
import PageHero from '../components/PageHero.astro';
|
||||
import ServiceInquiryDrawer from '../components/ServiceInquiryDrawer.astro';
|
||||
import { pageHeroImages } from '../data/page-visuals';
|
||||
const { entry } = Astro.props;
|
||||
const showForm = ['client-consultation', 'schedule-consultation'].includes(entry.data.slug);
|
||||
const showForm = ['client-consultation', 'schedule-consultation', 'referrals'].includes(entry.data.slug);
|
||||
const serviceTitles: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
'aba-therapy': 'Behavioral',
|
||||
@@ -56,14 +55,13 @@ const pageTitles: Record<string, Record<string, string>> = {
|
||||
const bannerTitle = serviceTitles[entry.data.lang]?.[entry.data.slug] || pageTitles[entry.data.lang]?.[entry.data.slug] || entry.data.title.split('|')[0].trim();
|
||||
const isService = Boolean(serviceTitles[entry.data.lang]?.[entry.data.slug]);
|
||||
const route = entry.data.lang === 'en' ? `/${entry.data.slug}` : `/${entry.data.lang}/${entry.data.slug}`;
|
||||
const isTour = entry.data.lang === 'en' && entry.data.slug === 'tour';
|
||||
const heroImage = pageHeroImages[entry.data.slug];
|
||||
if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team) {
|
||||
throw new Error('The English Team page requires structured team frontmatter.');
|
||||
}
|
||||
---
|
||||
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage || heroImage} lang={entry.data.lang}>
|
||||
{(entry.data.slug === 'contact' || 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>}
|
||||
{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>}
|
||||
<PageHero title={entry.data.displayH1 ?? bannerTitle} subtitle={entry.data.slug === 'faqs' ? 'FAQs' : undefined} image={heroImage} constrain />
|
||||
{entry.data.slug === 'team' && entry.data.lang === 'en'
|
||||
? <TeamPage content={entry.data.team!} />
|
||||
@@ -84,7 +82,6 @@ if (entry.data.slug === 'team' && entry.data.lang === 'en' && !entry.data.team)
|
||||
: entry.data.slug === 'contact'
|
||||
? <ContactPage title={bannerTitle} lang={entry.data.lang}><slot /></ContactPage>
|
||||
: <article class:list={['prose', 'source-page', 'container', { 'service-page': isService }]}><slot /></article>}
|
||||
{isTour && <OpenStreetMap lang={entry.data.lang} />}
|
||||
{isService && (entry.data.lang === 'en' || entry.data.lang === 'es') && <ServiceInquiryDrawer service={entry.data.slug} lang={entry.data.lang} route={route} />}
|
||||
{showForm && <FormShell lang={entry.data.lang} route={route} title={bannerTitle} logicalForm="service" showBackground={entry.data.slug === 'client-consultation'} />}
|
||||
{showForm && <FormShell lang={entry.data.lang} route={route} title={bannerTitle} logicalForm={entry.data.slug === 'referrals' ? 'referral' : 'service'} showBackground={entry.data.slug === 'client-consultation'} />}
|
||||
</BaseLayout>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
||||
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
|
||||
import { portalLinkPresentation } from '../utils/portal-presentation';
|
||||
|
||||
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
|
||||
@@ -25,8 +24,6 @@ export function safePortalContent(html: string): DocumentFragment {
|
||||
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
|
||||
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
|
||||
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. */ }
|
||||
}
|
||||
@@ -79,19 +76,6 @@ function enhanceStaffPortalPresentation(fragment: DocumentFragment): void {
|
||||
}
|
||||
if (!templatesList) templatesList = templatesHeading.closest('section, article')?.querySelector('ul') ?? null;
|
||||
templatesList?.classList.add('portal-template-grid');
|
||||
// Remove markers only from staff lists containing resource buttons.
|
||||
for (const list of fragment.querySelectorAll('ul, ol')) {
|
||||
const items = [...list.children].filter(
|
||||
(child) => child.tagName === 'LI'
|
||||
);
|
||||
|
||||
if (
|
||||
items.length > 0 &&
|
||||
items.every((item) => item.querySelector('a.portal-resource-button'))
|
||||
) {
|
||||
list.classList.add('portal-resource-list');
|
||||
}
|
||||
}
|
||||
}
|
||||
function initialize() {
|
||||
const root = document.querySelector<HTMLElement>('[data-employee-portal]');
|
||||
@@ -102,9 +86,6 @@ function initialize() {
|
||||
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
|
||||
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
|
||||
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>;
|
||||
try {
|
||||
if (!crypto.subtle) throw new Error();
|
||||
@@ -112,47 +93,6 @@ function initialize() {
|
||||
input.disabled = false; button.disabled = false;
|
||||
feedback.textContent = '';
|
||||
} 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 () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
@@ -163,12 +103,8 @@ function initialize() {
|
||||
for (const stream of data.streams) {
|
||||
const plain = await decryptPortalStream(stream, pass);
|
||||
if (plain !== null) {
|
||||
const role = portalRoleForStreamId(stream.id);
|
||||
if (!role) throw new Error('Unknown portal stream');
|
||||
credential = { role, code: pass };
|
||||
content.replaceChildren(safePortalContent(plain));
|
||||
prompt.hidden = true; content.hidden = false;
|
||||
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
|
||||
input.value = ''; content.focus(); return;
|
||||
}
|
||||
}
|
||||
@@ -178,9 +114,8 @@ function initialize() {
|
||||
};
|
||||
button.addEventListener('click', unlock);
|
||||
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
|
||||
// Credentials exist only in this page closure and are discarded on exit/swap.
|
||||
addEventListener('pagehide', clearSession, { once: true });
|
||||
document.addEventListener('astro:before-swap', clearSession, { once: true });
|
||||
// BFCache must not restore an already unlocked view.
|
||||
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
|
||||
}
|
||||
initialize();
|
||||
document.addEventListener('astro:page-load', initialize);
|
||||
@@ -1,248 +1,2 @@
|
||||
const likeStorageKey = 'aia-liked-articles-v1';
|
||||
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';
|
||||
/** Live counter behavior is intentionally not enabled by this migration. */
|
||||
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
|
||||
@@ -2,8 +2,8 @@
|
||||
.prose h2, .prose h3 { margin-top: 1.75em; }
|
||||
.prose img { border-radius: var(--radius-md); margin-block: var(--space-lg); }
|
||||
.prose a { overflow-wrap: anywhere; text-decoration: none; }
|
||||
.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-accent); }
|
||||
.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):is(:hover, :focus-visible) { color: var(--color-primary-dark); }
|
||||
.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 p { color: var(--color-primary); margin: 0 0 1.55rem; }
|
||||
|
||||
@@ -17,7 +17,7 @@ a:hover, a:active { color: var(--color-accent); }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent-strong);
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-accent);
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -26,13 +26,6 @@ a:hover, a:active { color: var(--color-accent); }
|
||||
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) {
|
||||
margin-block: 0 1.5rem;
|
||||
padding-inline-start: 2.5rem;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/** Arabic article chrome; preserve the existing English/Spanish labels. */
|
||||
const english = {
|
||||
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',
|
||||
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
|
||||
related: 'Similar Blog Posts', readFull: 'Read full post',
|
||||
};
|
||||
const arabic: typeof english = {
|
||||
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
|
||||
unlike: 'إزالة الإعجاب', countersReady: '', counterError: 'إحصاءات التفاعل غير متاحة مؤقتًا',
|
||||
counterUnavailable: 'إحصاءات التفاعل غير متاحة',
|
||||
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
|
||||
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
|
||||
|
||||
@@ -30,6 +30,8 @@ export function resolveCounterConfig(
|
||||
return { mode: 'live', apiBase, reason: 'live-approved' };
|
||||
}
|
||||
|
||||
export function counterModeForRuntime(config: CounterConfig): CounterMode {
|
||||
return config.mode;
|
||||
export function counterModeForRuntime(config: CounterConfig): 'off' | 'mock' {
|
||||
// Live behavior is intentionally not implemented by the migration. Keep an
|
||||
// approved future setting fail-closed until a separately authorized adapter exists.
|
||||
return config.mode === 'mock' ? 'mock' : 'off';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export type FormMode = 'live' | 'off' | 'mock';
|
||||
export type LogicalForm = 'general' | 'service';
|
||||
export type LogicalForm = 'general' | 'service' | 'referral';
|
||||
export type MockFormResult = 'success' | 'failed' | 'unavailable';
|
||||
|
||||
export interface FormModeConfig {
|
||||
@@ -170,6 +170,7 @@ export const formRouteMapping: ReadonlyArray<{
|
||||
{ route: '/client-consultation', logicalForm: 'service', kind: 'native-instance' },
|
||||
{ route: '/es/client-consultation', logicalForm: 'service', kind: 'native-instance' },
|
||||
{ route: '/schedule-consultation', logicalForm: 'service', kind: 'native-instance' },
|
||||
{ route: '/referrals', logicalForm: 'referral', kind: 'native-instance' },
|
||||
{ route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' },
|
||||
{ route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' },
|
||||
{ route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' },
|
||||
|
||||
@@ -10,7 +10,7 @@ const messages = {
|
||||
mock: 'Demo only: this local fixture never sends information.', mockSuccess: 'Demo only: the local fixture completed without sending anything.', mockFailed: 'Demo only: the local fixture simulated a failed submission.', mockUnavailable: 'Demo only: the local fixture simulated an unavailable service.',
|
||||
map: 'Map showing Arizona Institute for Autism in Scottsdale',
|
||||
tile: 'OpenStreetMap tile showing area around Arizona Institute for Autism in Scottsdale, AZ',
|
||||
zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism', directions: 'Get Directions',
|
||||
zoomIn: 'Zoom in', zoomOut: 'Zoom out', marker: 'Arizona Institute for Autism',
|
||||
formAPreview: {
|
||||
internalPreview: 'Internal meeting preview', heading: 'General enquiries preview',
|
||||
name: 'Your name', phone: 'Phone number', email: 'Email address',
|
||||
@@ -35,7 +35,7 @@ const messages = {
|
||||
mock: 'Solo demostración: esta prueba local nunca envía información.', mockSuccess: 'Solo demostración: la prueba local terminó sin enviar información.', mockFailed: 'Solo demostración: la prueba local simuló un envío fallido.', mockUnavailable: 'Solo demostración: la prueba local simuló un servicio no disponible.',
|
||||
map: 'Mapa de la ubicación de Arizona Institute for Autism en Scottsdale',
|
||||
tile: 'Mapa de OpenStreetMap de los alrededores de Arizona Institute for Autism en Scottsdale, Arizona',
|
||||
zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism', directions: 'Cómo llegar',
|
||||
zoomIn: 'Acercar', zoomOut: 'Alejar', marker: 'Arizona Institute for Autism',
|
||||
formAPreview: {
|
||||
internalPreview: 'Vista previa para reunión interna', heading: 'Vista previa de consultas generales',
|
||||
name: 'Su nombre', phone: 'Número de teléfono', email: 'Correo electrónico',
|
||||
|
||||
@@ -6,17 +6,7 @@ export function isFormACompletion(event: { origin: string; source: unknown; data
|
||||
// never from the message, current child location, query string or referrer.
|
||||
let expected: URL;
|
||||
try { expected = new URL(approvedUrl); } catch { return false; }
|
||||
if (expected.protocol !== 'https:') 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;
|
||||
if (expected.protocol !== 'https:' || event.origin !== expected.origin) return false;
|
||||
let data = event.data;
|
||||
if (typeof data === 'string') {
|
||||
if (data.length > 4096) return false;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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.';
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
// Build-only reader; never imported by a browser script.
|
||||
import fs from 'node:fs';
|
||||
import { validatePortalPayload } from './portal-crypto.mjs';
|
||||
/** Build-only reader. Local preview and approved public publication are distinct. */
|
||||
export function readPortalInput({ localApproved, productionApproved, indexing, localFile, productionFile }) {
|
||||
const local = localApproved === 'true';
|
||||
const production = productionApproved === 'true';
|
||||
if (local && production) throw new Error('Choose either local-preview or production portal ciphertext, never both');
|
||||
const file = local ? localFile : production ? productionFile : undefined;
|
||||
const permitted = (local && indexing === 'false') || (production && indexing === 'true');
|
||||
if (!permitted) {
|
||||
if (local || production) throw new Error('Portal ciphertext approval does not match this publication mode');
|
||||
return undefined;
|
||||
}
|
||||
if (!file) throw new Error('Approved portal ciphertext file is required for this build');
|
||||
export function readPortalInput({ approved, indexing, file }) {
|
||||
if (approved !== 'true' || indexing !== 'false' || !file) return undefined;
|
||||
try {
|
||||
if (fs.lstatSync(file).isSymbolicLink() || fs.statSync(file).size > 12_000_000) throw new Error('Portal ciphertext must be a regular bounded file');
|
||||
if (fs.statSync(file).size > 12_000_000) return undefined;
|
||||
return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8')));
|
||||
} catch { throw new Error('Approved portal ciphertext is missing or malformed'); }
|
||||
} catch { return undefined; }
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
/** 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; });
|
||||
@@ -1,92 +0,0 @@
|
||||
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 }); }
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/** 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; }
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* 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; });
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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 }); }
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
/** Owner-run private portal preparation. Access codes are accepted only by hidden TTY prompts. */
|
||||
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
|
||||
import { 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(':', '-');
|
||||
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
|
||||
async function hiddenPrompt(label) {
|
||||
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
|
||||
process.stderr.write(label);
|
||||
@@ -28,123 +24,67 @@ async function hiddenPrompt(label) {
|
||||
process.stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
async function readJson(file) { return JSON.parse(await fs.readFile(file, 'utf8')); }
|
||||
async function atomicPrivateJson(file, value) {
|
||||
const temporary = `${file}.tmp-${process.pid}`;
|
||||
await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
await fs.chmod(temporary, 0o600);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
async function backup(file, label) {
|
||||
try { await fs.copyFile(file, path.join(privateRoot, `${label}-${timestamp()}.json`), fs.constants.COPYFILE_EXCL); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
}
|
||||
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')));
|
||||
}
|
||||
async function identifyStreams(payload) {
|
||||
const staffCode = await hiddenPrompt('Staff access code: ');
|
||||
const managementCode = await hiddenPrompt('Management access code: ');
|
||||
const identify = async (code) => {
|
||||
const unlocked = [];
|
||||
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');
|
||||
return unlocked[0];
|
||||
};
|
||||
const staff = await identify(staffCode), management = await identify(managementCode);
|
||||
if (staff === management) throw new Error('Staff and management codes must identify different streams');
|
||||
return { staff, management, staffCode, managementCode };
|
||||
}
|
||||
async function identifyCommand(payload) {
|
||||
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));
|
||||
}
|
||||
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);
|
||||
const [command, source, ...extra] = process.argv.slice(2);
|
||||
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt');
|
||||
// No scanning for credentials. The original is read only; never copied into Git.
|
||||
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8')));
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(root, 0o700);
|
||||
const manifestPath = path.join(root, 'manifest.json');
|
||||
let previous = { resources: [] };
|
||||
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
|
||||
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
|
||||
const nextStreams = [], unlocked = [];
|
||||
let rewritten = 0;
|
||||
for (let index = 0; index < payload.streams.length; index++) {
|
||||
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
|
||||
const stream = payload.streams[index];
|
||||
let html = await decryptPortalStream(stream, pass);
|
||||
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
|
||||
// Reject identical credentials across views to preserve stream separation.
|
||||
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
|
||||
for (const ref of resourceReferences(html, stream.id)) {
|
||||
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
|
||||
record.references.push(ref); resources.set(ref.sourceUrl, record);
|
||||
}
|
||||
if (command === 'rewrite') {
|
||||
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id)));
|
||||
html = result.html; rewritten += result.count;
|
||||
const next = await encryptPortalStream(stream.id, html, pass);
|
||||
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
|
||||
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
|
||||
nextStreams.push(next);
|
||||
}
|
||||
unlocked.push(stream.id); html = ''; pass = '';
|
||||
}
|
||||
const stamp = new Date().toISOString().replaceAll(':', '-');
|
||||
if (command === 'collect') {
|
||||
const approvedOrigins = Array.isArray(previous.approvedOrigins)
|
||||
? previous.approvedOrigins.filter((origin) => {
|
||||
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; }
|
||||
catch { return false; }
|
||||
})
|
||||
: undefined;
|
||||
for (const [url, record] of resources) {
|
||||
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
|
||||
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
|
||||
}
|
||||
const manifest = {
|
||||
version: 1,
|
||||
updated: stamp,
|
||||
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
|
||||
views: unlocked,
|
||||
resources: [...resources.values()],
|
||||
};
|
||||
// Retain each previous manifest; no original evidence is overwritten.
|
||||
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
||||
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
|
||||
} else {
|
||||
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
const list = [...resources.values()];
|
||||
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2));
|
||||
}
|
||||
main().catch(() => { console.error('Private portal preparation stopped. Check codes, source, mapping evidence and private-file permissions locally. No credential or decrypted content details are logged.'); process.exitCode = 1; });
|
||||
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; });
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"contract": "AIAMarketing/contabo-vps website-release.py manifest schema",
|
||||
"contaboCommit": "a421a7b1049244baefa8a6e52efcf97ad2dfa89f",
|
||||
"input": {
|
||||
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"files": {
|
||||
"index.html": "<title>Synthetic</title>",
|
||||
"_astro/entry.abcdefgh.js": "export {}"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"directories": [
|
||||
"_astro"
|
||||
],
|
||||
"files": {
|
||||
"_astro/entry.abcdefgh.js": {
|
||||
"sha256": "f4c5cf9bb78e85f15dc27180260637cf24b2a24bc39e0788783a3accc4dde614",
|
||||
"size": 9
|
||||
},
|
||||
"index.html": {
|
||||
"sha256": "3a958d616a9a1f0d9229cff90395efb5155029a7f31d018c198c0c7d4cf28989",
|
||||
"size": 24
|
||||
}
|
||||
},
|
||||
"immutableAssets": [
|
||||
"_astro/entry.abcdefgh.js"
|
||||
],
|
||||
"schema": 1,
|
||||
"sourceCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"treeSha256": "4ca2127ce195f1975cbe85fdea800fb3177972afa3a1692794ba403939288694"
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1075,7 @@ test('Phase 7 privacy page preserves policy sections and reciprocal eligible nav
|
||||
});
|
||||
|
||||
test('Phase 9 off-mode form surfaces localize Spanish without enabling submission', () => {
|
||||
for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/schedule-consultation']) {
|
||||
for (const route of ['/es/contact','/es/client-consultation','/contact','/client-consultation','/referrals','/schedule-consultation']) {
|
||||
const $ = load(artifacts.get('staging').html.get(route));
|
||||
const form = $('[data-aia-form]');
|
||||
assert.equal(form.length, 1, route);
|
||||
@@ -1089,22 +1089,10 @@ test('Phase 9 off-mode form surfaces localize Spanish without enabling submissio
|
||||
assert.match(form.text(), route.startsWith('/es/') ? /El envío en línea aún no está habilitado/ : /Online submission is not yet connected/);
|
||||
}
|
||||
const $ = load(artifacts.get('staging').html.get('/es/contact'));
|
||||
assert.match($('.openstreetmap-section').attr('aria-label'), /^Mapa/);
|
||||
assert.equal($('#aia-openstreetmap').attr('data-zoom-in'), 'Acercar');
|
||||
assert.equal($('#aia-openstreetmap').attr('data-zoom-out'), 'Alejar');
|
||||
assert.match($('#aia-openstreetmap').attr('data-tile-alt'), /^Mapa de OpenStreetMap/);
|
||||
});
|
||||
|
||||
test('referrals retains the existing hosted Jotform CTA without rendering a local form', () => {
|
||||
for (const mode of ['staging', 'indexing']) {
|
||||
const $ = load(artifacts.get(mode).html.get('/referrals'));
|
||||
const link = $('main a').filter((_i, element) => $(element).text().trim() === 'Refer a Client');
|
||||
assert.equal(link.length, 1, mode);
|
||||
assert.equal(link.attr('href'), 'https://form.jotform.com/231638510219149');
|
||||
assert.ok(link.hasClass('content-button'));
|
||||
assert.equal($('[data-aia-form], [data-jotform-form-a], #form-title').length, 0);
|
||||
assert.equal($('main form').length, 0);
|
||||
}
|
||||
assert.match($('.contact-map-section').attr('aria-label'), /^Mapa/);
|
||||
assert.equal($('#aia-contact-map').attr('data-zoom-in'), 'Acercar');
|
||||
assert.equal($('#aia-contact-map').attr('data-zoom-out'), 'Alejar');
|
||||
assert.match($('#aia-contact-map').attr('data-tile-alt'), /^Mapa de OpenStreetMap/);
|
||||
});
|
||||
|
||||
test('Phase 7 author labels and Arabic credential isolation preserve archive SEO', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fg from 'fast-glob';
|
||||
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
|
||||
|
||||
const readText = async (file) => fs.readFile(file, 'utf8');
|
||||
|
||||
@@ -37,12 +36,6 @@ function hasTerminalSlash(url) {
|
||||
return pathname !== '/' && pathname.endsWith('/');
|
||||
}
|
||||
|
||||
function portalPayload(html) {
|
||||
const match = html.match(/<script[^>]*data-portal-payload[^>]*>([\s\S]*?)<\/script>/i);
|
||||
if (!match || match[1].trim() === 'null') return undefined;
|
||||
try { return validatePortalPayload(JSON.parse(match[1])); } catch { return null; }
|
||||
}
|
||||
|
||||
const formAContactLandingUrls = new Map([
|
||||
['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'],
|
||||
['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'],
|
||||
@@ -85,27 +78,20 @@ function formAAdapters(html) {
|
||||
return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]);
|
||||
}
|
||||
|
||||
function unsafeSignals(html, allowApprovedFormA = false, allowApprovedCounters = false) {
|
||||
function unsafeSignals(html, allowApprovedFormA = false) {
|
||||
const signals = [];
|
||||
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 (/<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');
|
||||
const counterReviewed = allowApprovedCounters
|
||||
? 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');
|
||||
if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin');
|
||||
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');
|
||||
return signals;
|
||||
}
|
||||
|
||||
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }, counters = 'off') {
|
||||
export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
|
||||
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(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 stagingPolicy = mode === 'staging';
|
||||
const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map();
|
||||
@@ -120,7 +106,6 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
let secretSignals = [];
|
||||
let formAIframes = 0;
|
||||
let deferredFormAIframes = 0;
|
||||
let counterInstances = 0;
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
const html = await readText(file);
|
||||
@@ -133,20 +118,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
|
||||
const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined;
|
||||
const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined;
|
||||
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 signals = unsafeSignals(html, formAEnabled);
|
||||
const jotformFrames = iframeSources(html).filter((src) => {
|
||||
try {
|
||||
return new URL(src).origin === 'https://form.jotform.com';
|
||||
@@ -170,18 +142,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
}
|
||||
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`);
|
||||
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 (/<script[^>]*data-portal-payload[^>]*>\s*(?!null\s*<)[\s\S]*?\"streams\"[\s\S]*?<\/script>/i.test(html)) errors.push(`${route}: private portal ciphertext is not an approved release artifact`);
|
||||
if (!title) errors.push(`${route}: missing title`);
|
||||
if (!canonical) errors.push(`${route}: missing canonical`);
|
||||
if (!robots) errors.push(`${route}: missing robots directive`);
|
||||
@@ -231,7 +192,6 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
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 (counters === 'live' && counterInstances === 0) errors.push('live artifact contains no article counters');
|
||||
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
|
||||
|
||||
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
|
||||
@@ -239,7 +199,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
const digest = createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).replaceAll(path.sep, '/');
|
||||
if (/(?:^|\/)(?:private|hubfs|staff-resources|management-resources)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
|
||||
if (/(?:^|\/)(?:private|hubfs)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
|
||||
if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
|
||||
const contents = await fs.readFile(file, 'utf8');
|
||||
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
|
||||
@@ -261,8 +221,6 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
|
||||
return {
|
||||
mode,
|
||||
forms,
|
||||
counters,
|
||||
portal: portal.mode,
|
||||
htmlRoutes: htmlFiles.length,
|
||||
sitemapUrls: sitemapRoutes.length,
|
||||
hreflangLinks,
|
||||
@@ -283,10 +241,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im
|
||||
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'staging';
|
||||
const root = process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'dist';
|
||||
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
|
||||
const 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);
|
||||
const result = await verifyArtifact(path.resolve(root), mode, forms);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.passed) process.exitCode = 1;
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { verifyArtifact } from './phase10-artifact-verify.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||
|
||||
async function fixture(mode = 'staging', forms = 'live') {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-'));
|
||||
@@ -110,19 +108,6 @@ test('artifact rejects production API origin even when Form A is approved', asyn
|
||||
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 () => {
|
||||
const root = await fixture('staging');
|
||||
await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact');
|
||||
@@ -154,30 +139,10 @@ test('release verification rejects private portal resources and encrypted payloa
|
||||
await fs.appendFile(path.join(root, 'index.html'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
|
||||
const result = await verifyArtifact(root, 'staging');
|
||||
assert.equal(result.passed, false);
|
||||
assert.ok(result.errors.some((error) => error.includes('portal ciphertext')));
|
||||
assert.ok(result.errors.some((error) => error.includes('private portal ciphertext')));
|
||||
assert.ok(result.errors.some((error) => error.includes('private/legacy')));
|
||||
});
|
||||
|
||||
test('approved production portal accepts only the expected two-stream ciphertext on the noindex route', async () => {
|
||||
const root = await fixture('indexing');
|
||||
const payload = { alg: portalAlgorithm, streams: [
|
||||
await encryptPortalStream('staff', '<p>Synthetic staff</p>', 'synthetic-staff-code'),
|
||||
await encryptPortalStream('management', '<p>Synthetic management</p>', 'synthetic-management-code'),
|
||||
] };
|
||||
const portal = path.join(root, 'employee-portal/index.html');
|
||||
await fs.mkdir(path.dirname(portal), { recursive: true });
|
||||
await fs.writeFile(portal, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(payload) + '</script></body></html>');
|
||||
const sha256 = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
const result = await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 });
|
||||
assert.equal(result.passed, true, result.errors.join('; '));
|
||||
await fs.appendFile(portal, '<!-- changed -->');
|
||||
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, true);
|
||||
const corrupted = JSON.parse(JSON.stringify(payload));
|
||||
corrupted.streams[0].ct = (corrupted.streams[0].ct[0] === 'A' ? 'B' : 'A') + corrupted.streams[0].ct.slice(1);
|
||||
await fs.writeFile(portal, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(corrupted) + '</script></body></html>');
|
||||
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, false);
|
||||
});
|
||||
|
||||
|
||||
test('artifact rejects extra routes, wrong origins and prematurely loaded drawers', async () => {
|
||||
const root = await fixture();
|
||||
@@ -195,5 +160,4 @@ test('artifact rejects extra routes, wrong origins and prematurely loaded drawer
|
||||
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','staging','invalid'), /live, off, or mock/);
|
||||
await assert.rejects(verifyArtifact('/unused','staging','live',{ mode: 'none' },'invalid'), /counters must be live/);
|
||||
});
|
||||
@@ -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', 'true').mode, 'live');
|
||||
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
|
||||
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'live');
|
||||
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'off');
|
||||
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);
|
||||
});
|
||||
|
||||
test('form mapping covers eleven native instances and five landing journeys', () => {
|
||||
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 11);
|
||||
test('form mapping covers twelve native instances and five landing journeys', () => {
|
||||
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'native-instance').length, 12);
|
||||
assert.equal(formRouteMapping.filter((entry) => entry.kind === 'landing-journey').length, 5);
|
||||
assert.equal(logicalFormForRoute('/contact'), 'general');
|
||||
assert.equal(logicalFormForRoute('/es/client-consultation'), 'service');
|
||||
assert.equal(logicalFormForRoute('/referrals'), undefined);
|
||||
assert.equal(logicalFormForRoute('/referrals'), 'referral');
|
||||
for (const route of ['/es/aba-therapy', '/es/autism-evaluations', '/es/learner-social-club']) {
|
||||
assert.equal(logicalFormForRoute(route), 'service');
|
||||
}
|
||||
@@ -73,15 +73,10 @@ test('inactive source has no native form or live vendor destination', () => {
|
||||
assert.match(sources, /data-form-mode/);
|
||||
});
|
||||
|
||||
test('counter source requires explicit live configuration and omits credentials', () => {
|
||||
const component = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
|
||||
const runtime = fs.readFileSync('src/scripts/likes-views.ts', 'utf8');
|
||||
const deploy = fs.readFileSync('tools/deploy.mjs', 'utf8');
|
||||
assert.doesNotMatch(component, /api\.azinstitute4autism\.com/);
|
||||
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'/);
|
||||
test('counter source has no implicit production fallback or credentialed fetch', () => {
|
||||
const source = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
|
||||
assert.doesNotMatch(source, /api\.azinstitute4autism\.com/);
|
||||
assert.doesNotMatch(source, /credentials:\s*['"]include/);
|
||||
assert.doesNotMatch(source, /method:\s*['"](?:POST|DELETE)/);
|
||||
assert.match(source, /data-counter-mode/);
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/** 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 };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/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"
|
||||
@@ -2,7 +2,6 @@ import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { approvedMapping } from './portal-production.mjs';
|
||||
export const sourceOrigin = 'https://www.azinstitute4autism.com';
|
||||
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
@@ -29,17 +28,16 @@ export function resourceReferences(html, view) {
|
||||
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
|
||||
return references;
|
||||
}
|
||||
export function rewriteResources(html, records, role) {
|
||||
export function rewriteResources(html, records) {
|
||||
const mappings = new Map();
|
||||
for (const record of records) {
|
||||
const mapping = role ? approvedMapping(record, role) : null;
|
||||
// Public links use their legacy, explicitly approved single-path contract.
|
||||
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 (!mapping && !publicMapping) continue;
|
||||
const deliveryPath = mapping?.deliveryPath ?? record.deliveryPath;
|
||||
if (!record.rewriteApproved) continue;
|
||||
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping');
|
||||
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping');
|
||||
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control');
|
||||
for (const ref of record.references) {
|
||||
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
|
||||
mappings.set(ref.original, deliveryPath);
|
||||
mappings.set(ref.original, record.deliveryPath);
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
|
||||
@@ -548,7 +548,7 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
|
||||
['/es/aba-therapy', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/es/autism-evaluations', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/es/learner-social-club', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/referrals', 'Refer a Client', 'https://form.jotform.com/231638510219149'],
|
||||
['/referrals', 'Refer a Client', '#form-title'],
|
||||
['/aba-therapy-intake-process', 'Take the First Step', '/client-consultation'],
|
||||
['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'],
|
||||
['/services', 'Consult with a Client Advocate', '/client-consultation'],
|
||||
@@ -563,11 +563,6 @@ test('Phase 5A conversion CTAs resolve to the intended published local route or
|
||||
assert.equal(link.length, 1, `${route}: ${label}`);
|
||||
assert.equal(link.attr('href'), href, route);
|
||||
const target = new URL(href, site + route);
|
||||
if (target.origin !== new URL(site).origin) {
|
||||
assert.equal(route, '/referrals', 'only the explicitly approved referral CTA leaves the site');
|
||||
assert.equal(target.href, 'https://form.jotform.com/231638510219149');
|
||||
continue;
|
||||
}
|
||||
const destination = page(target.pathname);
|
||||
if (target.hash) assert.equal(destination(`[id="${target.hash.slice(1)}"]`).length, 1, href);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
|
||||
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 moduleName = process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
@@ -35,7 +33,7 @@ const routes = [
|
||||
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
|
||||
];
|
||||
const widths = [320, 390, 768, 1440];
|
||||
const evidence = { forms, browserName, routes, widths, checks: 0, blocked: [], jotformAttempts: [], resourceRequests: [], screenshots: [], fallbackChecks: [], status: 'failed' };
|
||||
const evidence = { forms, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' };
|
||||
const reserve = net.createServer();
|
||||
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
|
||||
const port = reserve.address().port;
|
||||
@@ -49,11 +47,9 @@ function expectedUrl(locale) {
|
||||
}
|
||||
|
||||
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 }
|
||||
const { chromium } = await import(moduleName);
|
||||
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||
: { headless: true });
|
||||
evidence.browser = browser.version();
|
||||
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
|
||||
@@ -74,38 +70,17 @@ try {
|
||||
const duration = 30 * 86400000;
|
||||
const staffCode = 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 staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In & 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: [
|
||||
await encryptPortalStream('b', `<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="${staffResourcePath}">Staff's Safety & Training: Résumé (2026)!</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">` + styledFixture + staffTemplates, staffCode),
|
||||
await encryptPortalStream('a', `<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="${managementResourcePath}">Management – Policies / Q&A #1</a>` + styledFixture, managementCode),
|
||||
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('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + 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) => {
|
||||
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
||||
await context.route('**/*', async (route) => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
|
||||
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') {
|
||||
const response = await route.fetch();
|
||||
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
|
||||
@@ -352,14 +327,14 @@ try {
|
||||
await input.fill(staffCode); await input.press('Enter');
|
||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Synthetic Management|Management fixture only/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/);
|
||||
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined);
|
||||
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
|
||||
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
|
||||
assert.equal(await input.inputValue(), '');
|
||||
const checkPresentation = async (staff = false) => {
|
||||
const content = p.locator('[data-portal-content]');
|
||||
assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 4);
|
||||
assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 3);
|
||||
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('th[scope="col"]').count(), 2);
|
||||
@@ -371,7 +346,7 @@ try {
|
||||
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.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28));
|
||||
assert.equal(await content.locator('.portal-contact-link').first().evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
|
||||
assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
|
||||
await button.focus();
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
|
||||
@@ -386,26 +361,6 @@ try {
|
||||
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: '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));
|
||||
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
||||
@@ -415,24 +370,6 @@ try {
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
||||
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');
|
||||
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
|
||||
|
||||
@@ -10,28 +10,17 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
|
||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
import { isFormACompletion } from '../src/utils/jotform-completion.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 an exact approved origin, active frame window and string Form A ID', () => {
|
||||
test('completion envelope requires exact origin, active frame window and string Form A ID', () => {
|
||||
const source = {};
|
||||
const check = (event, frame, active) => isFormACompletion(event, frame, active,
|
||||
'https://form.jotform.com/262946078314059?formContext=consultation&language=en');
|
||||
const data = { action: 'submission-completed', formID: '262946078314059' };
|
||||
const event = { origin: 'https://form.jotform.com', source, data };
|
||||
assert.equal(check(event, source, true), true);
|
||||
assert.equal(check({ ...event, origin: 'https://hipaa-submit.jotform.com' }, source, true), true);
|
||||
assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true);
|
||||
for (const change of [
|
||||
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'https://hipaa-submit.jotform.com.evil.invalid' },
|
||||
{ origin: 'https://other.jotform.com' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
|
||||
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
|
||||
{ source: {} }, { source: null }, { data: { ...data, formID: 'other' } },
|
||||
{ data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } },
|
||||
{ data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) },
|
||||
@@ -56,65 +45,6 @@ test('portal presentation translates only explicit button tokens/brand fills and
|
||||
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 () => {
|
||||
const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8');
|
||||
const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8');
|
||||
@@ -124,13 +54,7 @@ test('staff template presentation follows sanitized headings, not original HubSp
|
||||
assert.match(renderer, /templatesList\?\.classList\.add\('portal-template-grid'\)/);
|
||||
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
assert.match(css, /@media \(max-width: 767px\)/);
|
||||
assert.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/);
|
||||
assert.match(css, /ul\.portal-template-grid a\.portal-resource-button/);
|
||||
});
|
||||
|
||||
test('global preference validates expiry, corrupted values and clock anomalies', () => {
|
||||
@@ -176,28 +100,12 @@ 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 refs = resourceReferences(html, 'synthetic');
|
||||
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
|
||||
const record = { references: refs, rewriteApproved: true, mappingApproved: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
|
||||
assert.equal(rewriteResources(html, [record], 'staff').html, html);
|
||||
const result = rewriteResources(html, [{ ...record, accessPolicyApproved: true }], 'staff');
|
||||
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
|
||||
assert.throws(() => rewriteResources(html, [record]));
|
||||
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]);
|
||||
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
|
||||
assert.equal(rewriteResources(html, []).html, html);
|
||||
});
|
||||
test('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 () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
|
||||
try {
|
||||
@@ -211,26 +119,21 @@ test('private collector validates binary, reuses hash, rejects errors and unappr
|
||||
} finally { await fs.rm(dir, { recursive: true }); }
|
||||
});
|
||||
|
||||
test('portal input isolates local preview from explicit indexing production payloads', async () => {
|
||||
test('portal input requires every local gate and rejects missing or malformed ciphertext', async () => {
|
||||
const { readPortalInput } = await import('../src/utils/portal-input.mjs');
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-'));
|
||||
try {
|
||||
const pass = randomBytes(24).toString('base64');
|
||||
const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '<p>Synthetic</p>', pass)));
|
||||
const file = path.join(dir, 'fixture.json'); await fs.writeFile(file, JSON.stringify({ alg: portalAlgorithm, streams }), { mode: 0o600 });
|
||||
const options = { localApproved: 'true', indexing: 'false', localFile: file };
|
||||
const options = { approved: 'true', indexing: 'false', file };
|
||||
assert.ok(readPortalInput(options));
|
||||
// Form modes cannot substitute for the independent private-input approval.
|
||||
for (const mode of ['live', 'off', 'mock']) {
|
||||
assert.ok(readPortalInput({ ...options, mode }));
|
||||
assert.equal(readPortalInput({ indexing: 'false', localFile: file, mode }), undefined);
|
||||
assert.equal(readPortalInput({ indexing: 'false', file, mode }), undefined);
|
||||
}
|
||||
for (const changed of [{ localApproved: undefined }, { localApproved: 'false' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
|
||||
assert.throws(() => readPortalInput({ ...options, indexing: 'true' }), /publication mode/);
|
||||
assert.ok(readPortalInput({ productionApproved: 'true', indexing: 'true', productionFile: file }));
|
||||
assert.throws(() => readPortalInput({ productionApproved: 'true', indexing: 'true' }), /required/);
|
||||
assert.throws(() => readPortalInput({ localApproved: 'true', productionApproved: 'true', indexing: 'false', localFile: file, productionFile: file }), /either/);
|
||||
await fs.writeFile(file, '<h1>Not encrypted</h1>');
|
||||
assert.throws(() => readPortalInput(options), /missing or malformed/);
|
||||
for (const changed of [{ approved: undefined }, { approved: 'false' }, { indexing: 'true' }, { indexing: undefined }, { file: '/missing-fixture' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
|
||||
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), undefined);
|
||||
} finally { await fs.rm(dir, { recursive: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user