Compare commits

10 Commits
34 changed files with 1592 additions and 213 deletions

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@
packages = developmentPackages ++ [ pkgs.playwright-driver ]; packages = developmentPackages ++ [ pkgs.playwright-driver ];
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs"; PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers { PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
withFirefox = false; withFirefox = true;
withWebkit = false; withWebkit = false;
withFfmpeg = false; withFfmpeg = false;
}; };
+24 -4
View File
@@ -83,6 +83,8 @@ The default values are:
```txt ```txt
PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com PUBLIC_AIA_API_BASE=https://api.azinstitute4autism.com
PUBLIC_COUNTERS_MODE=off
PUBLIC_COUNTERS_LIVE_APPROVED=false
PUBLIC_ALLOW_INDEXING=false PUBLIC_ALLOW_INDEXING=false
``` ```
@@ -94,6 +96,16 @@ not deploy anything or change environment files. Run `npm run build` afterward
to restore staging output. Only explicitly authorized production deployments to restore staging output. Only explicitly authorized production deployments
should publish indexing-enabled artifacts. should publish indexing-enabled artifacts.
Ordinary local builds keep counters off. `npm run deploy` deliberately and
non-overridably builds the production artifact with live counters, the approved
API origin and `PUBLIC_COUNTERS_LIVE_APPROVED=true`. Article pages post one
first-party view per real navigation; Library cards only batch-read statistics.
Likes use the existing POST/DELETE API and store only the visitor's chosen slug
set in localStorage. No view cookies or visitor identifiers are created. Run
`npm run test:counters`, then build in live mode and run `npm run
verify:counters -- --browser=chromium` (or `firefox`) for a fully intercepted
browser regression that cannot write production counters.
## View And Build ## View And Build
### Development Server ### Development Server
@@ -600,16 +612,18 @@ Use `PUBLIC_AIA_FORMS_MODE=off npm run build` for an offline artifact, or
`PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty `PUBLIC_AIA_FORMS_MODE=mock npm run dev` for synthetic fixtures. Missing/empty
mode means live; obsolete `isolated-test` and other invalid values produce an mode means live; obsolete `isolated-test` and other invalid values produce an
explicit configuration error. Remove old approval/environment variables from explicit configuration error. Remove old approval/environment variables from
local settings. Indexing is independent of forms; counters remain off by default. local settings. Indexing and form mode remain independent of counter mode.
See the [current Form A guide](integrations/jotform/README.md) for the exact See the [current Form A guide](integrations/jotform/README.md) for the exact
registry, safeguards, rollback and owner acceptance requirements. Form C remains registry, safeguards, rollback and owner acceptance requirements. Form C remains
inactive. Employee-portal private input retains its own separate gates. inactive. Employee-portal private input retains its own separate gates.
Verify matching output with Verify matching output with
`node tools/phase10-artifact-verify.mjs --mode=staging --forms=live`; `node tools/phase10-artifact-verify.mjs --mode=staging --forms=live --counters=off`;
use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock` use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
for those explicit builds. No actual submissions run in automated tests. for those explicit builds. Production counter artifacts require
`--counters=live`. No actual submissions or production counter writes run in
automated tests.
## Static releases and employee portal ## Static releases and employee portal
@@ -619,7 +633,13 @@ dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. The server-side installer is a root-only 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 POSIX shell script using NixOS base tools; it does not require Node.js on
Contabo. Ordinary releases never copy employee documents into `dist`; Contabo. Ordinary releases never copy employee documents into `dist`;
protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is 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. explicitly non-PHI.
### Google Ads consultation-form conversion handoff ### Google Ads consultation-form conversion handoff
+68 -43
View File
@@ -5,6 +5,18 @@ staff and management portal content and the interim shared-code presentation.
Browser-side decryption remains access friction rather than individual Browser-side decryption remains access friction rather than individual
authentication, revocation, a secure session, or a HIPAA determination. 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 ## Approved production ciphertext
An indexing-enabled production build may include one owner-approved encrypted An indexing-enabled production build may include one owner-approved encrypted
@@ -23,71 +35,84 @@ approved release and protected-resource installation commands.
## Source and private collection ## Source and private collection
Read-only legacy footer source: The 42 approved non-PHI resources have already been collected under the ignored
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`. `private/employee-portal/hubfs/` workspace. Collection and clinical review are
The original stays outside Git as rollback evidence. No access code was available complete. Do not run a new collection pass for production preparation.
to the agent. The exact resource count and real content compatibility remain
unverified. Do not infer the earlier approximate 43 links as a measured count.
From `www`, in the owner's private interactive terminal: Production preparation is an owner-only, hidden-prompt sequence. The first
command identifies the encrypted streams from the staff and management codes;
the codes are never accepted in arguments, environment variables, files or
output:
```sh ```sh
node tools/employee-portal-migrate.mjs collect \ npm run portal:prepare -- identify \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- propose-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt /home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
``` ```
Enter each legitimate view code at its hidden prompt. Never place codes in Review the private mapping summary. The role rule maps staff-only references to
arguments, environment variables, shell history or reports. The utility checks `/staff-resources/`, management-only references to `/management-resources/`, and
both streams, keeps plaintext in memory, and collects only referenced `/hubfs/` duplicates a shared source into both protected namespaces. After the owner
resources from the verified source origin. Other origins require explicit approves that operational policy, run:
review/code allowlisting; redirects are bounded and never inherit credentials.
No Jotform, Google or other vendor forms are fetched. ```sh
npm run portal:prepare -- approve-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- rewrite-production \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
The rewrite command prompts for both codes again, rewrites every approved legacy
resource URL, re-encrypts with fresh salts and IVs, verifies each role privately,
and atomically installs `approved-production-ciphertext.json` plus a matching
private `production-readiness.json`. It refuses unresolved `/hubfs/` references.
Prior manifests and production ciphertext are retained as timestamped private
backups.
### Mapping approval versus server verification
`mappingApproved`, `accessPolicyApproved`, and `rewriteApproved` mean the
verified collected source has an approved role and safe protected destination
and may be prepared or installed. They do not claim the destination exists.
`destinationInstalled`, `destinationHashVerified`,
`destinationPermissionsVerified`, and `destinationAccessVerified` remain false
until an authorized VPS rehearsal actually installs the file and verifies its
bytes, `root:nginx` permissions, Basic Auth boundary and protected URL. Local
preparation and installation accept the first state; release acceptance must
still complete the second state before browser cutover.
Read-only legacy footer source:
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`.
The original stays outside Git as rollback evidence. The 42-file collection and
its accepted content are complete; no current command downloads resources.
Private output is `private/employee-portal/`: directories 0700, manifests and Private output is `private/employee-portal/`: directories 0700, manifests and
binary resources 0600. This exact subtree is gitignored and outside Astro's binary resources 0600. This exact subtree is gitignored and outside Astro's
public directory. Filenames are SHA-256 hashes of source URLs, independently public directory. Filenames are SHA-256 hashes of source URLs, independently
verified by content hashes. The manifest retains original references, labels, verified by content hashes. The manifest retains original references, labels,
view membership, MIME, bytes, status and delivery/classification decisions. view membership, MIME, bytes, status and delivery/classification decisions.
All new resources default to UNRESOLVED. Publicly reachable does not mean public The manifest preserves the original source URL, collection hash, size, reference
approved. Resuming verifies an existing file's content hash; mismatches are not membership and accepted source evidence. Public legacy reachability did not
overwritten. HTML error documents, executable responses, invalid known signatures, infer public approval.
redirects outside approved destinations and resources over 50 MiB are rejected.
The first pass allows only the verified site origin. If a legitimate `/hubfs/`
response redirects to a HubSpot CDN, inspect that exact HTTPS origin first, then
add its origin to the private manifest's `approvedOrigins` list and rerun. Never
approve a redirect based only on a failed download or a broadly shared vendor
domain. Failures remain private review items. The utility prints counts only.
Before every artifact transfer, verify that only `dist` is copied and that it Before every artifact transfer, verify that only `dist` is copied and that it
contains no private manifest, document or real ciphertext unless that ciphertext contains no private manifest, document or real ciphertext unless that ciphertext
has separately been authorized for the local preview. Never archive the whole has separately been authorized for the local preview. Never archive the whole
working directory as a deployable artifact. working directory as a deployable artifact.
## Approved rewriting ## Historical collection and rewriting recovery notes
The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL, The original source, collection manifests, collected bytes and prior ciphertext
MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`, revisions remain recovery evidence. Do not change their classifications or
`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for destination flags by hand; use the production-preparation sequence above so
internal documents) `destinationAccessControlled` only with actual evidence. stream identity, references, source hashes and protected destinations remain
The utility does not create those destinations or infer their approval. consistent. CSS/srcset dependencies remain an explicit error rather than a
silent rewrite.
```sh
node tools/employee-portal-migrate.mjs rewrite \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
It reauthenticates both views, changes approved quoted href/src attributes only,
preserves unrelated HTML/labels, and re-encrypts with fresh salts and IVs. It
verifies round-trip decryption and distinct codes before writing new ciphertext
with exclusive filenames. The original source is untouched. CSS/srcset mappings
require separate review and are rejected rather than silently rewritten.
Unresolved links retain their original destination as a recorded dependency,
not a claim of verified destination health. A changed manifest alone never
changes links in the encrypted stream.
## Local preview and content safety ## Local preview and content safety
Use a private ciphertext file generated by `collect` or `rewrite`: Use a retained private ciphertext revision:
```sh ```sh
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \ AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \
+55 -9
View File
@@ -7,11 +7,14 @@ It does not run `nixos-rebuild`, reload nginx, change DNS, or write directly to
## Required local tools and private inputs ## Required local tools and private inputs
- Node.js 22+, Git, tar, SSH and rsync. - Node.js 22+, Git, tar, SSH and rsync.
- A normal SSH identity accepted by the existing root receiver: - Either an OpenSSH host alias already configured locally, or a normal SSH
`AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`. identity accepted by the existing root receiver.
- `AIA_DEPLOY_HOST=administrator@host` (or the approved equivalent). - For environment-only invocations: `AIA_DEPLOY_HOST=administrator@host`,
- For a real public release, an owner-controlled, regular encrypted payload: `AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`, and
`AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`. `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 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 only; neither access code nor decrypted portal HTML enters an Astro environment
@@ -23,6 +26,15 @@ From `www/`:
```sh ```sh
npm run deploy -- --dry-run 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, Dry-run performs the local production build, tests, artifact verification,
@@ -37,8 +49,27 @@ AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE="$PWD/private/employee-portal/approved-pro
npm run deploy -- --confirm 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 confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and
counters off, then uploads only fresh commit-specific `staging/source-<sha>` and the approved live counters configuration, then uploads only fresh commit-specific `staging/source-<sha>` and
`staging/dist-<sha>` directories. It runs, in order: `staging/dist-<sha>` directories. It runs, in order:
```text ```text
@@ -61,7 +92,6 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
## Employee resources: separate one-time operation ## Employee resources: separate one-time operation
Approved non-PHI employee files are never part of an ordinary release. After Approved non-PHI employee files are never part of an ordinary release. After
the website release that contains `portal-resource-install.sh` is promoted and
the VPS SOPS-backed Basic Auth material is independently ready: the VPS SOPS-backed Basic Auth material is independently ready:
```sh ```sh
@@ -72,18 +102,34 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:portal-resources -- --confirm npm run deploy:portal-resources -- --confirm
``` ```
The installer invokes the promoted root-only `portal-resource-install.sh` through For the first install, the client extracts `portal-resource-install.sh` from the
NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally current reviewed Git commit, uploads it into the root-controlled commit staging
installed Node.js runtime. It reads only the private collection manifest, 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 accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to
`/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and `/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 uploads a root-controlled temporary tree. It never uses `--delete` for resource
directories and refuses to replace a different existing file. The VPS installs directories and refuses to replace a different existing file. The VPS installs
regular files as `root:nginx` mode `0640` and reports metadata only. 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, Before public DNS cutover, test the site with approved host-header/SNI methods,
including the portal’s noindex response, valid ciphertext unlock, both protected including the portal’s noindex response, valid ciphertext unlock, both protected
resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal
shared-code rotation is coordinated: update VPS SOPS inputs and privately 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 re-encrypt the browser payload together. This interim portal is non-PHI and is
not an employee identity system. 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.
+3
View File
@@ -13,6 +13,7 @@
"deploy": "node tools/deploy.mjs", "deploy": "node tools/deploy.mjs",
"deploy:rollback": "node tools/deploy-rollback.mjs", "deploy:rollback": "node tools/deploy-rollback.mjs",
"deploy:portal-resources": "node tools/deploy-portal-resources.mjs", "deploy:portal-resources": "node tools/deploy-portal-resources.mjs",
"portal:prepare": "node tools/employee-portal-migrate.mjs",
"preview": "astro preview", "preview": "astro preview",
"extract:blog-footers": "node tools/extract-blog-footers.mjs", "extract:blog-footers": "node tools/extract-blog-footers.mjs",
"crawl:live": "node tools/crawl-live-site.mjs", "crawl:live": "node tools/crawl-live-site.mjs",
@@ -26,6 +27,8 @@
"test:seo": "node --experimental-strip-types --test tools/seo.test.mjs", "test:seo": "node --experimental-strip-types --test tools/seo.test.mjs",
"test:library": "node --experimental-strip-types --test tools/library.test.mjs", "test:library": "node --experimental-strip-types --test tools/library.test.mjs",
"test:campaign": "node --test tools/campaign.test.mjs", "test:campaign": "node --test tools/campaign.test.mjs",
"test:counters": "node --experimental-strip-types --test tools/counters.test.mjs",
"verify:counters": "node tools/counters-browser.mjs",
"verify:campaign": "node tools/campaign-browser.mjs", "verify:campaign": "node tools/campaign-browser.mjs",
"generate:sitemap": "node tools/generate-sitemap.mjs", "generate:sitemap": "node tools/generate-sitemap.mjs",
"generate:redirects": "node tools/generate-redirects.mjs" "generate:redirects": "node tools/generate-redirects.mjs"
+18
View File
@@ -21,6 +21,24 @@ private manifests and resources must never enter Git, `dist`, source snapshots,
logs or deployment manifests. `/employee-portal` remains noindex and excluded logs or deployment manifests. `/employee-portal` remains noindex and excluded
from sitemap/hreflang. Form A, referral and publication behavior are unchanged. 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) ## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one The owner directs `/referrals` to retain the current public-site workflow: one
+1 -1
View File
@@ -27,7 +27,7 @@ const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد'
</article> </article>
<style> <style>
.author-byline { color: inherit; text-decoration: none; } .author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
.author-byline:hover { text-decoration: underline; } .author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; } .author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
.blog-card-meta { align-items: center; display: flex; font-size: .78rem; gap: .75rem; } .blog-card-meta { align-items: center; display: flex; font-size: .78rem; gap: .75rem; }
+7 -5
View File
@@ -1,8 +1,8 @@
--- ---
import { articleMessages } from '../utils/article-messages'; import { articleMessages } from '../utils/article-messages';
import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode'; import { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode';
interface Props { slug: string; lang: string } interface Props { slug: string; lang: string; trackView?: boolean }
const { slug, lang } = Astro.props; const { slug, lang, trackView = false } = Astro.props;
const text = articleMessages(lang); const text = articleMessages(lang);
const counterConfig = resolveCounterConfig( const counterConfig = resolveCounterConfig(
import.meta.env.PUBLIC_COUNTERS_MODE, import.meta.env.PUBLIC_COUNTERS_MODE,
@@ -15,21 +15,23 @@ const mockResult = import.meta.env.PUBLIC_COUNTERS_MOCK_RESULT === 'failed' || i
: 'success'; : 'success';
const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`; const statusId = `counter-status-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
--- ---
<div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-mock-result={mockResult}> <div class="like-view-counter" data-counter data-counter-mode={runtimeMode} data-counter-reason={counterConfig.reason} data-api-base={counterConfig.apiBase} data-slug={slug} data-track-view={trackView ? 'true' : 'false'} data-like-label={text.like} data-unlike-label={text.unlike} data-ready-message={text.countersReady} data-error-message={text.counterError} data-mock-result={mockResult}>
<span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span> <span aria-label={text.articleViews}><span class="view-count" data-views dir={lang === 'ar' ? 'ltr' : undefined}>—</span> {text.views}</span>
<span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}> <span class="like-group" role={lang === 'ar' ? 'group' : undefined} aria-label={lang === 'ar' ? text.likes : undefined}>
<span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span> <span class="like-count" data-likes dir={lang === 'ar' ? 'ltr' : undefined}>—</span>
<button class="like-button" type="button" data-like data-slug={slug} aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode === 'off'}> <button class="like-button" type="button" data-like aria-label={text.like} aria-describedby={statusId} aria-pressed="false" disabled={runtimeMode !== 'mock'}>
<svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon"> <svg aria-hidden="true" viewBox="0 0 12 10" width="20" height="20" focusable="false" class="like-icon">
<path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path> <path d="m1 4.4c-1-4 2.5-5 4.5-2.5l.5 .6l.5-.6c2-2.5 6-1.5 4.5 2.5q-1 3-5 5q-4-2-5-5z"></path>
</svg> </svg>
</button> </button>
</span> </span>
<span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : text.counterUnavailable}</span> <span id={statusId} class="counter-status" aria-live="polite">{runtimeMode === 'mock' ? 'Demo metrics only' : runtimeMode === 'off' ? text.counterUnavailable : ''}</span>
</div> </div>
<script> <script>
import { bindMockForms } from '../scripts/mock-form'; import { bindMockForms } from '../scripts/mock-form';
import { bindLiveCounters } from '../scripts/likes-views';
bindMockForms(); bindMockForms();
void bindLiveCounters();
</script> </script>
<style> <style>
@@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript> <noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div> </div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div> <div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<p class="employee-portal__download-feedback" role="status" aria-live="polite" hidden data-portal-download-feedback></p>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script> <script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section> </section>
@@ -35,6 +36,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; } input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; } input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content:focus { outline: none; } .employee-portal__content:focus { outline: none; }
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
[hidden] { display: none; } [hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); } .employee-portal__content :global(a) { color: var(--color-primary); }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; } .employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
@@ -5,6 +5,7 @@ displayH1: "Early Autism Intervention Makes a Difference"
slug: "lp/early-intervention" slug: "lp/early-intervention"
lang: "en" lang: "en"
canonical: "https://www.azinstitute4autism.com/lp/early-intervention" canonical: "https://www.azinstitute4autism.com/lp/early-intervention"
noindex: true
campaign: "early_intervention" campaign: "early_intervention"
--- ---
+1 -1
View File
@@ -15,7 +15,7 @@ Last updated: February 19, 2025
At Arizona Institute for Autism, we are dedicated to protecting the privacy and security of our website visitors, clients, and their families. This policy explains how we collect, use, and safeguard your personal information. At Arizona Institute for Autism, we are dedicated to protecting the privacy and security of our website visitors, clients, and their families. This policy explains how we collect, use, and safeguard your personal information.
As a healthcare provider, we strictly adhere to the [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index) (Health Insurance Portability and Accountability Act), [COPPA](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-312) (Children's Online Privacy Protection Act), and all applicable federal and state regulations governing the privacy and security of [PHI](https://cphs.berkeley.edu/hipaa/hipaa18) (Protected Health Information). As a healthcare provider, we strictly adhere to the [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index) (Health Insurance Portability and Accountability Act), [COPPA](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-312) (Children's Online Privacy Protection Act), and all applicable federal and state regulations governing the privacy and security of [PHI](https://cphs.berkeley.edu/hipaa/hipaa18.html) (Protected Health Information).
## Information We Collect ## Information We Collect
+3 -2
View File
@@ -30,10 +30,11 @@ const { entry } = await getPublication(Astro.url.pathname);
:global(.library-intro h2) { color: white; } :global(.library-intro h2) { color: white; }
:global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: minmax(0, 1fr) 240px; } :global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: minmax(0, 1fr) 240px; }
:global(.library-sidebar) { position: sticky; top: 1rem; } :global(.library-sidebar) { position: sticky; top: 1rem; }
:global(.library-sidebar :is(h1, h2, h3, h4, h5, h6)) { font-family: var(--font-body); }
:global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; } :global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; }
:global(.library-sidebar ul) { list-style: none; padding: 0; } :global(.library-sidebar ul) { list-style: none; padding: 0; }
:global(.library-sidebar li) { border-bottom: 1px solid var(--color-border); padding-block: .65rem; } :global(.library-sidebar li) { border-bottom: .15em solid var(--color-accent-strong); padding-block: .65rem; }
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; } :global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; line-height: 1.25; }
:global(.blog-list) { display: block; } :global(.blog-list) { display: block; }
:global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; } :global(.library-search-results[hidden]), :global(.library-archive[hidden]), :global(.blog-list[hidden]) { display: none; }
:global(.library-search-results h2) { margin-top: 0; } :global(.library-search-results h2) { margin-top: 0; }
+2 -2
View File
@@ -26,13 +26,13 @@ const schema = blogPostingSchema(entry, publication, authors, site.url);
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>} {entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
</header> </header>
<div class="prose blog-post-body container"><slot /></div> <div class="prose blog-post-body container"><slot /></div>
<div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} /></div> <div class="container"><LikeViewCounter slug={entry.data.slug} lang={entry.data.lang} trackView /></div>
</article> </article>
<BlogPostFooter entryId={entry.id} lang={entry.data.lang} /> <BlogPostFooter entryId={entry.id} lang={entry.data.lang} />
</BaseLayout> </BaseLayout>
<style> <style>
.author-byline { color: inherit; text-decoration: none; } .author-byline { color: var(--color-accent-strong); font-weight: 600; text-decoration: none; }
.author-byline:hover { text-decoration: underline; } .author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; } .author-byline:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
.article-header { padding-block: 4.5rem 0; text-align: left; } .article-header { padding-block: 4.5rem 0; text-align: left; }
+54 -2
View File
@@ -1,4 +1,5 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs'; import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
import { portalLinkPresentation } from '../utils/portal-presentation'; import { portalLinkPresentation } from '../utils/portal-presentation';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media, /** Build a passive DOM, never inject executable decrypted HTML. Remote media,
@@ -24,6 +25,8 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation; if (presentation) element.className = presentation;
const resource = protectedPortalResource(url.href, location.origin, source.textContent ?? '');
if (resource) element.dataset.portalResourceRole = resource.role;
} }
} catch { /* Invalid destination remains visible text without navigation. */ } } catch { /* Invalid destination remains visible text without navigation. */ }
} }
@@ -99,6 +102,9 @@ function initialize() {
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!; const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!; const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!; const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
const downloadFeedback = root.querySelector<HTMLElement>('[data-portal-download-feedback]')!;
let credential: { role: 'staff' | 'management'; code: string } | null = null;
let downloading = false;
let data: ReturnType<typeof validatePortalPayload>; let data: ReturnType<typeof validatePortalPayload>;
try { try {
if (!crypto.subtle) throw new Error(); if (!crypto.subtle) throw new Error();
@@ -106,6 +112,47 @@ function initialize() {
input.disabled = false; button.disabled = false; input.disabled = false; button.disabled = false;
feedback.textContent = ''; feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; } } catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const clearSession = () => {
credential = null;
input.value = '';
content.replaceChildren();
content.hidden = true;
prompt.hidden = false;
downloadFeedback.hidden = true;
downloadFeedback.textContent = '';
};
const saveDownload = (blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove();
// Both supported browsers have ample time to hand the Blob to their
// download manager before its temporary URL is released.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
};
content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return;
event.preventDefault();
if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin, target.textContent ?? '');
if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false;
return;
}
downloading = true;
target.setAttribute('aria-disabled', 'true');
downloadFeedback.textContent = 'Downloading document…';
downloadFeedback.hidden = false;
void fetchProtectedPortalResource(resource, credential)
.then(({ blob, filename }) => {
saveDownload(blob, filename);
downloadFeedback.textContent = `Downloaded ${filename}.`;
})
.catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
.finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
});
const unlock = async () => { const unlock = async () => {
if (button.disabled) return; if (button.disabled) return;
button.disabled = true; button.disabled = true;
@@ -116,8 +163,12 @@ function initialize() {
for (const stream of data.streams) { for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass); const plain = await decryptPortalStream(stream, pass);
if (plain !== null) { if (plain !== null) {
const role = portalRoleForStreamId(stream.id);
if (!role) throw new Error('Unknown portal stream');
credential = { role, code: pass };
content.replaceChildren(safePortalContent(plain)); content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false; prompt.hidden = true; content.hidden = false;
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
input.value = ''; content.focus(); return; input.value = ''; content.focus(); return;
} }
} }
@@ -127,8 +178,9 @@ function initialize() {
}; };
button.addEventListener('click', unlock); button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } }); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
// BFCache must not restore an already unlocked view. // Credentials exist only in this page closure and are discarded on exit/swap.
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; }); addEventListener('pagehide', clearSession, { once: true });
document.addEventListener('astro:before-swap', clearSession, { once: true });
} }
initialize(); initialize();
document.addEventListener('astro:page-load', initialize); document.addEventListener('astro:page-load', initialize);
+248 -2
View File
@@ -1,2 +1,248 @@
/** Live counter behavior is intentionally not enabled by this migration. */ const likeStorageKey = 'aia-liked-articles-v1';
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode'; const viewCooldownStorageKey = 'aia-view-cooldowns-v1';
export const articleViewCooldownMs = 30 * 60 * 1_000;
const requestTimeoutMs = 8_000;
const pendingLikes = new Set<string>();
type CounterStats = Record<string, { likes: number; views: number }>;
type Fetcher = typeof fetch;
function endpoint(base: string, path: string) {
return `${base.replace(/\/$/, '')}${path}`;
}
function browserStorage(): Pick<Storage, 'getItem' | 'setItem'> {
try {
return localStorage;
} catch {
return { getItem: () => null, setItem: () => undefined };
}
}
function browserSessionStorage(): Pick<Storage, 'getItem' | 'setItem'> {
try {
return sessionStorage;
} catch {
return { getItem: () => null, setItem: () => undefined };
}
}
export function reserveArticleView(
slug: string,
storage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
now = Date.now(),
) {
let active: Record<string, number> = {};
try {
const parsed = JSON.parse(storage.getItem(viewCooldownStorageKey) ?? '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
active = Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, number] => {
const timestamp = entry[1];
return typeof timestamp === 'number'
&& Number.isFinite(timestamp)
&& now >= timestamp
&& now - timestamp < articleViewCooldownMs;
}));
}
} catch {
// Corrupt or blocked session storage must not prevent article rendering.
}
if (active[slug] !== undefined) return false;
active[slug] = now;
try {
storage.setItem(viewCooldownStorageKey, JSON.stringify(active));
} catch {
// The request may proceed when storage is unavailable.
}
return true;
}
export function readLikedSlugs(storage: Pick<Storage, 'getItem'> = browserStorage()) {
try {
const parsed = JSON.parse(storage.getItem(likeStorageKey) ?? '[]');
return new Set(Array.isArray(parsed) ? parsed.filter((slug): slug is string => typeof slug === 'string') : []);
} catch {
return new Set<string>();
}
}
function writeLikedSlugs(liked: Set<string>, storage: Pick<Storage, 'setItem'>) {
try {
storage.setItem(likeStorageKey, JSON.stringify([...liked].sort()));
} catch {
// The counter remains usable in memory when storage is unavailable.
}
}
async function requestJson(fetcher: Fetcher, url: string, init: RequestInit, timeoutMs = requestTimeoutMs) {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetcher(url, {
...init,
signal: controller.signal,
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
if (!response.ok) throw new Error(`Counter API returned ${response.status}`);
return await response.json();
} finally {
globalThis.clearTimeout(timeout);
}
}
export async function fetchCounterStats(apiBase: string, slugs: string[], fetcher: Fetcher = fetch, timeoutMs = requestTimeoutMs): Promise<CounterStats> {
return requestJson(fetcher, endpoint(apiBase, '/stats/batch'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...new Set(slugs)] }),
}, timeoutMs);
}
export async function reportArticleView(apiBase: string, slug: string, fetcher: Fetcher = fetch): Promise<number> {
const result = await requestJson(fetcher, endpoint(apiBase, '/views'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug }),
keepalive: true,
});
if (!Number.isInteger(result.views) || result.views < 0) throw new Error('Invalid view response');
return result.views;
}
export async function updateLike(apiBase: string, slug: string, liked: boolean, fetcher: Fetcher = fetch): Promise<number> {
const result = await requestJson(fetcher, endpoint(apiBase, liked ? `/likes/${encodeURIComponent(slug)}` : '/likes'), {
method: liked ? 'DELETE' : 'POST',
headers: liked ? undefined : { 'Content-Type': 'application/json' },
body: liked ? undefined : JSON.stringify({ slug }),
});
if (!Number.isInteger(result.count) || result.count < 0) throw new Error('Invalid like response');
return result.count;
}
function countersForSlug(root: ParentNode, slug: string) {
return [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')]
.filter((counter) => counter.dataset.slug === slug);
}
function render(root: ParentNode, slug: string, stats: { likes?: number; views?: number }) {
for (const counter of countersForSlug(root, slug)) {
if (stats.views !== undefined) {
const views = counter.querySelector<HTMLElement>('[data-views]');
if (views) views.textContent = String(stats.views);
}
if (stats.likes !== undefined) {
const likes = counter.querySelector<HTMLElement>('[data-likes]');
if (likes) likes.textContent = String(stats.likes);
}
}
}
function renderLiked(root: ParentNode, slug: string, liked: boolean) {
for (const counter of countersForSlug(root, slug)) {
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
if (!button) continue;
button.setAttribute('aria-pressed', String(liked));
button.classList.toggle('liked', liked);
button.setAttribute('aria-label', liked ? counter.dataset.unlikeLabel ?? 'Remove your like' : counter.dataset.likeLabel ?? 'Like this article');
}
}
function status(root: ParentNode, slug: string, failed: boolean) {
for (const counter of countersForSlug(root, slug)) {
const output = counter.querySelector<HTMLElement>('.counter-status');
if (output) output.textContent = failed ? counter.dataset.errorMessage ?? 'Engagement metrics are temporarily unavailable' : counter.dataset.readyMessage ?? '';
}
}
export async function bindLiveCounters(
root: ParentNode = document,
fetcher: Fetcher = fetch,
storage: Pick<Storage, 'getItem' | 'setItem'> = browserStorage(),
viewStorage: Pick<Storage, 'getItem' | 'setItem'> = browserSessionStorage(),
) {
const counters = [...root.querySelectorAll<HTMLElement>('[data-counter][data-counter-mode="live"]')];
const pending = counters.filter((counter) => counter.dataset.counterBound !== 'true');
if (!pending.length) return;
for (const counter of pending) counter.dataset.counterBound = 'true';
const liked = readLikedSlugs(storage);
const apiBase = pending[0].dataset.apiBase;
const slugs = [...new Set(pending.map((counter) => counter.dataset.slug).filter((slug): slug is string => Boolean(slug)))];
if (!apiBase || !slugs.length) return;
for (const slug of slugs) renderLiked(root, slug, liked.has(slug));
const viewRequests = pending
.filter((counter) => counter.dataset.trackView === 'true' && counter.dataset.viewReported !== 'true')
.map((counter) => {
const slug = counter.dataset.slug;
if (!slug) return undefined;
// Mark before the non-retried request. Repeated initialization and
// bfcache restoration reuse this DOM marker and cannot double-count.
counter.dataset.viewReported = 'true';
// A short per-tab reservation also suppresses a newly rendered copy of
// the same article after back navigation, without a cookie or visitor ID.
if (!reserveArticleView(slug, viewStorage)) return undefined;
return reportArticleView(apiBase, slug, fetcher)
.then((views) => ({ slug, views, failed: false }))
.catch(() => ({ slug, views: undefined, failed: true }));
})
.filter((request): request is NonNullable<typeof request> => Boolean(request));
// Start the pageview request immediately. Statistics retrieval and rendering
// remain nonblocking, and the later view response wins any read/increment race.
try {
const stats = await fetchCounterStats(apiBase, slugs, fetcher);
for (const slug of slugs) {
if (stats[slug]) render(root, slug, stats[slug]);
status(root, slug, false);
}
} catch {
for (const slug of slugs) status(root, slug, true);
} finally {
for (const counter of pending) {
const button = counter.querySelector<HTMLButtonElement>('[data-like]');
const slug = counter.dataset.slug;
if (!button || !slug) continue;
button.disabled = false;
button.addEventListener('click', async () => {
if (pendingLikes.has(slug)) return;
pendingLikes.add(slug);
for (const copy of countersForSlug(root, slug)) {
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
if (copyButton) copyButton.disabled = true;
}
const wasLiked = liked.has(slug);
try {
const count = await updateLike(apiBase, slug, wasLiked, fetcher);
wasLiked ? liked.delete(slug) : liked.add(slug);
writeLikedSlugs(liked, storage);
render(root, slug, { likes: count });
renderLiked(root, slug, !wasLiked);
status(root, slug, false);
} catch {
status(root, slug, true);
} finally {
pendingLikes.delete(slug);
for (const copy of countersForSlug(root, slug)) {
const copyButton = copy.querySelector<HTMLButtonElement>('[data-like]');
if (copyButton) copyButton.disabled = false;
}
}
});
}
}
for (const request of viewRequests) {
const result = await request;
if (result.failed || result.views === undefined) status(root, result.slug, true);
else render(root, result.slug, { views: result.views });
}
}
if (typeof document !== 'undefined') {
document.addEventListener('astro:page-load', () => void bindLiveCounters());
}
export { counterModeForRuntime, resolveCounterConfig } from '../utils/counter-mode.ts';
+2 -2
View File
@@ -2,8 +2,8 @@
.prose h2, .prose h3 { margin-top: 1.75em; } .prose h2, .prose h3 { margin-top: 1.75em; }
.prose img { border-radius: var(--radius-md); margin-block: var(--space-lg); } .prose img { border-radius: var(--radius-md); margin-block: var(--space-lg); }
.prose a { overflow-wrap: anywhere; text-decoration: none; } .prose a { overflow-wrap: anywhere; text-decoration: none; }
.prose :is(p, li, td) a:not(.content-button) { color: var(--color-primary); text-decoration: underline; text-underline-offset: .15em; } .prose :is(p, li, td) a:not(.content-button) { color: var(--color-accent-strong); text-decoration: underline; text-underline-offset: .15em; }
.prose :is(p, li, td) a:not(.content-button):is(:hover, :focus-visible) { color: var(--color-primary-dark); } .prose :is(p, li, td) a:not(.content-button):is(:hover, :focus-visible) { color: var(--color-accent); }
.blog-post-body blockquote { background: var(--color-tint); border: 0; border-radius: 16px; color: var(--color-primary); margin: 0 auto 2.5rem; max-width: 767px; padding: 45px; position: relative; z-index: 0; } .blog-post-body blockquote { background: var(--color-tint); border: 0; border-radius: 16px; color: var(--color-primary); margin: 0 auto 2.5rem; max-width: 767px; padding: 45px; position: relative; z-index: 0; }
.blog-post-body blockquote::before { background-image: url('/assets/images/blockquote.svg'); content: ""; display: block; height: 41px; inset-inline-start: 6px; position: absolute; top: 22px; width: 50px; z-index: -1; } .blog-post-body blockquote::before { background-image: url('/assets/images/blockquote.svg'); content: ""; display: block; height: 41px; inset-inline-start: 6px; position: absolute; top: 22px; width: 50px; z-index: -1; }
.blog-post-body blockquote p { color: var(--color-primary); margin: 0 0 1.55rem; } .blog-post-body blockquote p { color: var(--color-primary); margin: 0 0 1.55rem; }
+8 -1
View File
@@ -17,7 +17,7 @@ a:hover, a:active { color: var(--color-accent); }
.eyebrow { .eyebrow {
margin: 0; margin: 0;
color: var(--color-primary); color: var(--color-accent-strong);
font-family: var(--font-accent); font-family: var(--font-accent);
font-size: 2rem; font-size: 2rem;
} }
@@ -26,6 +26,13 @@ a:hover, a:active { color: var(--color-accent); }
margin-block-start: 0; margin-block-start: 0;
} }
.blog-card .text-link,
.blog-card .text-link:visited {
text-decoration-color: var(--color-accent-strong);
text-decoration-thickness: .15em;
text-underline-offset: .5em;
}
.prose :where(ul, ol) { .prose :where(ul, ol) {
margin-block: 0 1.5rem; margin-block: 0 1.5rem;
padding-inline-start: 2.5rem; padding-inline-start: 2.5rem;
+2
View File
@@ -1,12 +1,14 @@
/** Arabic article chrome; preserve the existing English/Spanish labels. */ /** Arabic article chrome; preserve the existing English/Spanish labels. */
const english = { const english = {
views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article', views: 'views', articleViews: 'Article views', likes: 'Likes', like: 'Like this article',
unlike: 'Remove your like', countersReady: '', counterError: 'Engagement metrics are temporarily unavailable',
counterUnavailable: 'Engagement metrics are unavailable', counterUnavailable: 'Engagement metrics are unavailable',
navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post', navigation: 'Article navigation', previous: 'Previous Post', next: 'Next Post',
related: 'Similar Blog Posts', readFull: 'Read full post', related: 'Similar Blog Posts', readFull: 'Read full post',
}; };
const arabic: typeof english = { const arabic: typeof english = {
views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال', views: 'مشاهدات', articleViews: 'مشاهدات المقال', likes: 'الإعجابات', like: 'الإعجاب بهذا المقال',
unlike: 'إزالة الإعجاب', countersReady: '', counterError: 'إحصاءات التفاعل غير متاحة مؤقتًا',
counterUnavailable: 'إحصاءات التفاعل غير متاحة', counterUnavailable: 'إحصاءات التفاعل غير متاحة',
navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي', navigation: 'التنقل بين المقالات', previous: 'المقال السابق', next: 'المقال التالي',
related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً', related: 'مقالات ذات صلة', readFull: 'قراءة المقال كاملاً',
+2 -4
View File
@@ -30,8 +30,6 @@ export function resolveCounterConfig(
return { mode: 'live', apiBase, reason: 'live-approved' }; return { mode: 'live', apiBase, reason: 'live-approved' };
} }
export function counterModeForRuntime(config: CounterConfig): 'off' | 'mock' { export function counterModeForRuntime(config: CounterConfig): CounterMode {
// Live behavior is intentionally not implemented by the migration. Keep an return config.mode;
// approved future setting fail-closed until a separately authorized adapter exists.
return config.mode === 'mock' ? 'mock' : 'off';
} }
+74
View File
@@ -0,0 +1,74 @@
const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
const serverExtension = /([.][a-z0-9]{1,10})$/i;
export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff';
if (id === 'a') return 'management';
return undefined;
}
export function recognizableDownloadFilename(label, serverFilename) {
const extension = serverExtension.exec(serverFilename)?.[1]?.toLowerCase();
if (!extension) return undefined;
let title = typeof label === 'string' ? label.trim() : '';
if (title.toLowerCase().endsWith(extension)) title = title.slice(0, -extension.length);
const cleaned = title
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(/[’']/g, '')
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
const base = [...cleaned].slice(0, 180).join('').replace(/-+$/g, '');
if (!base || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(base)) return `AIA-document${extension}`;
return `${base}${extension}`;
}
export function protectedPortalResource(href, origin, label = '') {
let url;
try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname);
if (!match) return undefined;
const downloadFilename = recognizableDownloadFilename(label, match[2]);
if (!downloadFilename) return undefined;
return { role: match[1], filename: match[2], downloadFilename, url: url.href };
}
function basicAuthorization(role, code) {
if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
throw new PortalDownloadError('session');
}
const bytes = new TextEncoder().encode(`${role}:${code}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
}
export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
const response = await fetcher(resource.url, {
method: 'GET',
headers: { Authorization: basicAuthorization(credential.role, credential.code) },
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
});
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: resource.downloadFilename };
}
export function portalDownloadErrorMessage(error) {
if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
}
return 'The document could not be downloaded. Please try again or contact AIA.';
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Browser regression for the built live-counter artifact. Every API request is
* intercepted and fulfilled locally; this script never writes production data.
*/
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const browserName = process.argv.find((argument) => argument.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
: 'playwright';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
await new Promise((resolve) => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const apiOrigin = 'https://api.azinstitute4autism.com';
const calls = [];
let views = 100;
let likes = 5;
let browser;
let preview;
try {
const drivers = await import(moduleName);
const driver = drivers[browserName];
const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath ? { headless: true, executablePath } : { headless: true });
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
cwd: root,
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
stdio: 'ignore',
});
const reachable = () => new Promise((resolve) => {
const request = http.get(`${origin}/`, (response) => { response.resume(); resolve(response.statusCode === 200); });
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
for (let attempt = 0; attempt < 60 && !(await reachable()); attempt += 1) await delay(250);
assert.equal(await reachable(), true, 'Loopback Astro preview did not start');
const context = await browser.newContext();
await context.route(`${apiOrigin}/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, DELETE, OPTIONS',
'access-control-allow-headers': 'content-type',
} });
return;
}
const body = request.postDataJSON?.() ?? undefined;
calls.push({ method: request.method(), path: url.pathname, body });
let response;
if (url.pathname === '/stats/batch') {
response = Object.fromEntries(body.slugs.map((slug) => [slug, { likes, views }]));
} else if (url.pathname === '/views') {
views += 1;
response = { views };
} else if (url.pathname === '/likes' && request.method() === 'POST') {
likes += 1;
response = { count: likes };
} else if (url.pathname.startsWith('/likes/') && request.method() === 'DELETE') {
likes = Math.max(0, likes - 1);
response = { count: likes };
} else {
await route.fulfill({ status: 404, body: '{}' });
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': origin },
body: JSON.stringify(response),
});
});
const page = await context.newPage();
const article = '/library/aba-school-readiness-guide';
await page.goto(`${origin}${article}`);
await page.locator('[data-counter][data-track-view="true"] [data-views]').waitFor();
await assert.doesNotReject(async () => page.waitForFunction(() =>
document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '101'));
assert.equal(calls.filter((call) => call.path === '/views').length, 1);
assert.deepEqual(calls.find((call) => call.path === '/views').body, { slug: 'aba-school-readiness-guide' });
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'true');
assert.equal(calls.filter((call) => call.path === '/likes' && call.method === 'POST').length, 1);
assert.equal(await page.evaluate(() => localStorage.length), 1);
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), ['aia-liked-articles-v1']);
await page.locator('[data-counter][data-track-view="true"] [data-like]').click();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-like]')?.getAttribute('aria-pressed') === 'false');
assert.equal(calls.filter((call) => call.method === 'DELETE').length, 1);
await page.reload();
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"] [data-views]')?.textContent === '102');
assert.equal(calls.filter((call) => call.path === '/views').length, 2, 'a real reload is a new view');
const beforeListing = calls.filter((call) => call.path === '/views').length;
await page.goto(`${origin}/library`);
await page.waitForFunction(() => document.querySelector('[data-counter] [data-views]')?.textContent !== '—');
assert.equal(calls.filter((call) => call.path === '/views').length, beforeListing, 'listing cards never report views');
for (const route of [
'/es/library/executive-functioning-skills-autism',
'/ar/library/executive-functioning-skills-autism',
]) {
await page.goto(`${origin}${route}`);
await page.waitForFunction(() => document.querySelector('[data-counter][data-track-view="true"]')?.dataset.viewReported === 'true');
}
const translated = calls.filter((call) => call.path === '/views').slice(-2);
assert.deepEqual(translated.map((call) => call.body.slug), [
'executive-functioning-skills-autism',
'executive-functioning-skills-autism',
]);
console.log(JSON.stringify({ browser: browserName, version: browser.version(), checks: 18, productionRequests: 0 }));
await context.close();
} finally {
if (preview) preview.kill('SIGTERM');
if (browser) await browser.close();
}
+246
View File
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import fg from 'fast-glob';
import matter from 'gray-matter';
import {
articleViewCooldownMs,
bindLiveCounters,
fetchCounterStats,
readLikedSlugs,
reportArticleView,
reserveArticleView,
updateLike,
} from '../src/scripts/likes-views.ts';
class FakeClassList {
values = new Set();
toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); }
}
class FakeButton {
attributes = new Map([['aria-pressed', 'false']]);
classList = new FakeClassList();
disabled = true;
listeners = [];
setAttribute(name, value) { this.attributes.set(name, value); }
getAttribute(name) { return this.attributes.get(name); }
addEventListener(name, callback) { if (name === 'click') this.listeners.push(callback); }
async click() { for (const callback of this.listeners) await callback(); }
}
class FakeCounter {
constructor(slug, { trackView = false, lang = 'en' } = {}) {
this.dataset = {
slug,
counterMode: 'live',
apiBase: 'https://api.example.test',
trackView: String(trackView),
likeLabel: lang === 'ar' ? 'إعجاب' : 'Like',
unlikeLabel: lang === 'ar' ? 'إزالة الإعجاب' : 'Unlike',
readyMessage: '',
errorMessage: lang === 'ar' ? 'خطأ مؤقت' : 'Temporarily unavailable',
};
this.views = { textContent: '—' };
this.likes = { textContent: '—' };
this.status = { textContent: '' };
this.button = new FakeButton();
}
querySelector(selector) {
return selector === '[data-views]' ? this.views
: selector === '[data-likes]' ? this.likes
: selector === '[data-like]' ? this.button
: selector === '.counter-status' ? this.status : null;
}
}
class FakeRoot {
constructor(counters) { this.counters = counters; }
querySelectorAll(selector) {
return selector === '[data-counter][data-counter-mode="live"]' ? this.counters : [];
}
}
class FakeStorage {
values = new Map();
getItem(key) { return this.values.get(key) ?? null; }
setItem(key, value) { this.values.set(key, value); }
}
function response(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
test('counter transport batches unique slugs and never sends credentials', async () => {
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
return response({ article: { likes: 3, views: 9 } });
};
const result = await fetchCounterStats('https://api.example.test/', ['article', 'article'], fetcher);
assert.equal(result.article.views, 9);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://api.example.test/stats/batch');
assert.equal(calls[0].options.credentials, 'omit');
assert.equal(calls[0].options.referrerPolicy, 'no-referrer');
assert.deepEqual(JSON.parse(calls[0].options.body), { slugs: ['article'] });
});
test('article views and likes use the existing API contracts', async () => {
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
return response(url.endsWith('/views') ? { views: 42 } : { count: 8 });
};
assert.equal(await reportArticleView('https://api.example.test', 'article', fetcher), 42);
assert.equal(await updateLike('https://api.example.test', 'article', false, fetcher), 8);
assert.equal(await updateLike('https://api.example.test', 'article', true, fetcher), 8);
assert.deepEqual(JSON.parse(calls[0].options.body), { slug: 'article' });
assert.equal(calls[0].options.keepalive, true);
assert.equal(calls[1].options.method, 'POST');
assert.equal(calls[2].options.method, 'DELETE');
assert.equal(calls[2].url, 'https://api.example.test/likes/article');
});
test('one article navigation increments once while cards only retrieve statistics', async () => {
const article = new FakeCounter('shared-slug', { trackView: true });
const duplicate = new FakeCounter('shared-slug');
const card = new FakeCounter('other-slug');
const root = new FakeRoot([article, duplicate, card]);
const storage = new FakeStorage();
const calls = [];
const fetcher = async (url, options) => {
calls.push({ url, options });
if (url.endsWith('/stats/batch')) return response({
'shared-slug': { likes: 4, views: 20 },
'other-slug': { likes: 1, views: 2 },
});
if (url.endsWith('/views')) return response({ views: 21 });
if (options.method === 'POST') return response({ count: 5 });
return response({ count: 4 });
};
await bindLiveCounters(root, fetcher, storage);
await bindLiveCounters(root, fetcher, storage);
assert.equal(calls.filter((call) => call.url.endsWith('/stats/batch')).length, 1);
assert.equal(calls.filter((call) => call.url.endsWith('/views')).length, 1);
assert.equal(article.views.textContent, '21');
assert.equal(duplicate.views.textContent, '21');
assert.equal(card.views.textContent, '2');
await article.button.click();
assert.equal(article.likes.textContent, '5');
assert.equal(duplicate.likes.textContent, '5');
assert.equal(article.button.getAttribute('aria-pressed'), 'true');
assert.deepEqual([...readLikedSlugs(storage)], ['shared-slug']);
await duplicate.button.click();
assert.equal(article.likes.textContent, '4');
assert.equal(duplicate.button.getAttribute('aria-pressed'), 'false');
assert.deepEqual([...readLikedSlugs(storage)], []);
});
test('a short per-tab cooldown suppresses back-navigation duplicates and then expires', async () => {
const likeStorage = new FakeStorage();
const viewStorage = new FakeStorage();
const calls = [];
const fetcher = async (url) => {
calls.push(url);
return response(url.endsWith('/views')
? { views: 21 }
: { 'shared-slug': { likes: 4, views: 20 } });
};
await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage);
await bindLiveCounters(new FakeRoot([new FakeCounter('shared-slug', { trackView: true })]), fetcher, likeStorage, viewStorage);
assert.equal(calls.filter((url) => url.endsWith('/views')).length, 1);
const clockStorage = new FakeStorage();
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000), true);
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs - 1), false);
assert.equal(reserveArticleView('shared-slug', clockStorage, 10_000 + articleViewCooldownMs), true);
assert.equal(reserveArticleView('different-slug', clockStorage, 10_000 + articleViewCooldownMs), true);
});
test('invalid or unavailable session storage fails open without persisting an identifier', () => {
const corrupt = new FakeStorage();
corrupt.setItem('aia-view-cooldowns-v1', '{broken');
assert.equal(reserveArticleView('article', corrupt, 10_000), true);
assert.deepEqual(JSON.parse(corrupt.getItem('aia-view-cooldowns-v1')), { article: 10_000 });
const blocked = {
getItem() { throw new Error('blocked'); },
setItem() { throw new Error('blocked'); },
};
assert.equal(reserveArticleView('article', blocked, 10_000), true);
});
test('API failures preserve rendering and expose localized inline status', async () => {
const counter = new FakeCounter('article', { trackView: true, lang: 'ar' });
const root = new FakeRoot([counter]);
await bindLiveCounters(root, async () => response({ error: 'offline' }, 503), new FakeStorage());
assert.equal(counter.status.textContent, 'خطأ مؤقت');
assert.equal(counter.button.disabled, false);
assert.equal(counter.dataset.viewReported, 'true');
});
test('like persistence rejects corrupt and non-array storage values', () => {
const storage = new FakeStorage();
storage.setItem('aia-liked-articles-v1', '{broken');
assert.deepEqual([...readLikedSlugs(storage)], []);
storage.setItem('aia-liked-articles-v1', JSON.stringify(['valid', 4, null]));
assert.deepEqual([...readLikedSlugs(storage)], ['valid']);
});
test('blocked local storage does not duplicate or disable a successful like', async () => {
const counter = new FakeCounter('article');
const root = new FakeRoot([counter]);
const storage = {
getItem() { throw new Error('blocked'); },
setItem() { throw new Error('blocked'); },
};
let writes = 0;
const fetcher = async (url) => {
if (url.endsWith('/stats/batch')) return response({ article: { likes: 2, views: 3 } });
writes += 1;
return response({ count: 3 });
};
await bindLiveCounters(root, fetcher, storage);
await counter.button.click();
assert.equal(writes, 1);
assert.equal(counter.likes.textContent, '3');
assert.equal(counter.button.getAttribute('aria-pressed'), 'true');
assert.equal(counter.button.disabled, false);
});
test('counter requests time out without retrying an ambiguous operation', async () => {
let calls = 0;
const hanging = (_url, options) => {
calls += 1;
return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(options.signal.reason)));
};
await assert.rejects(fetchCounterStats('https://api.example.test', ['article'], hanging, 5));
assert.equal(calls, 1);
});
test('only article layouts track views and translated publications retain exact legacy slugs', async () => {
const layout = await fs.readFile('src/layouts/BlogPostLayout.astro', 'utf8');
const card = await fs.readFile('src/components/BlogCard.astro', 'utf8');
assert.match(layout, /LikeViewCounter[^>]*trackView/);
assert.doesNotMatch(card, /trackView/);
const groups = new Map();
for (const file of await fg('src/content/blog/**/*.{md,mdx}')) {
const frontmatter = matter(await fs.readFile(file, 'utf8')).data;
if (!frontmatter.translationKey || frontmatter.draft) continue;
const entries = groups.get(frontmatter.translationKey) ?? [];
entries.push({ lang: frontmatter.lang, slug: frontmatter.slug, file: path.basename(file) });
groups.set(frontmatter.translationKey, entries);
}
const multilingual = [...groups.values()].filter((entries) => entries.length > 1);
assert.ok(multilingual.some((entries) => new Set(entries.map(({ lang }) => lang)).size === 3));
for (const entries of multilingual) {
assert.equal(new Set(entries.map(({ slug }) => slug)).size, 1, JSON.stringify(entries));
}
});
+25 -9
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { rsyncArguments, sshTarget } from './deploy.mjs'; import { rsyncArguments, sshTarget } from './deploy.mjs';
import { mappingsFor, approvedMapping } from './portal-production.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..'); const repositoryRoot = path.resolve(toolRoot, '..');
@@ -19,14 +20,18 @@ function run(command, args, { quiet = false } = {}) {
if (!quiet && result.stdout) process.stdout.write(result.stdout); if (!quiet && result.stdout) process.stdout.write(result.stdout);
return result.stdout.trim(); 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 safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
function roleAndName(record) { function roleAndName(record, mapping) {
if (!['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) fail('Every installed resource must have an approved internal classification'); const role = mapping?.role;
const role = record.classification === 'STAFF-INTERNAL' ? 'staff' : 'management'; if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
const prefix = `/${role}-resources/`; const prefix = `/${role}-resources/`;
if (!record.destinationImplemented || !record.destinationVerified || !record.destinationAccessControlled || !record.rewriteApproved || typeof record.deliveryPath !== 'string' || !record.deliveryPath.startsWith(prefix)) fail('Resource destination is not deployment-approved'); const name = mapping.deliveryPath.slice(prefix.length);
const name = record.deliveryPath.slice(prefix.length); if (!safeName(name) || mapping.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
if (!safeName(name) || record.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
return { role, name }; return { role, name };
} }
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) { export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
@@ -38,8 +43,9 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list'); if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
const seen = new Set(), files = []; const seen = new Set(), files = [];
for (const record of manifest.resources) { for (const record of manifest.resources) {
if (!['STAFF-INTERNAL','MANAGEMENT-INTERNAL'].includes(record.classification)) continue; for (const mapping of mappingsFor(record)) {
const { role, name } = roleAndName(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 (!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'); if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
seen.add(`${role}/${name}`); seen.add(`${role}/${name}`);
@@ -53,6 +59,7 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL); 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 }); 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'); 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}`)) }; return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
} }
@@ -86,6 +93,13 @@ async function main() {
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY; const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity); const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`; 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(' ')}`; const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]); run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) { for (const role of ['staff','management']) {
@@ -94,7 +108,9 @@ async function main() {
run('rsync', args); 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, planFile, `${host}:${remotePlan}`]);
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]); 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)); console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); } } finally { await fs.rm(temporary, { recursive: true, force: true }); }
} }
+17 -3
View File
@@ -27,7 +27,7 @@ async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('sy
const localFile = 'a'.repeat(64); const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 }); await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length, 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 }; return { localFile, bytes };
} }
@@ -40,8 +40,22 @@ test('resource plan copies only hash-verified approved protected resources', asy
assert.equal(plan.files.length, 1); assert.equal(plan.files.length, 1);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString()); 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/); assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] })); await 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), /destination/); 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 }); } } finally { await fs.rm(root, { recursive: true, force: true }); }
}); });
+76 -24
View File
@@ -9,8 +9,9 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { encryptPortalStream, portalAlgorithm, validatePortalPayload } from '../src/utils/portal-crypto.mjs'; import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
import { verifyArtifact } from './phase10-artifact-verify.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 toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..'); const repositoryRoot = path.resolve(toolRoot, '..');
@@ -68,7 +69,9 @@ export async function releaseManifest(dist, sourceCommit) {
} }
publicPath(child); publicPath(child);
const bytes = await fs.readFile(path.join(dist, child)); const bytes = await fs.readFile(path.join(dist, child));
if (bytes.includes(Buffer.from('PRIVATE KEY-----'))) fail(`Private-key material in artifact: ${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 }; files[child] = { sha256: sha256(bytes), size: bytes.length };
} }
} }
@@ -80,9 +83,24 @@ export async function releaseManifest(dist, sourceCommit) {
export function rsyncArguments(source, destination, { remove = false } = {}) { 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]; return ['-rlt', ...(remove ? ['--delete'] : []), '--links', '--chown=root:root', '--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', source.endsWith('/') ? source : `${source}/`, destination];
} }
export function sshTarget(host, identity) { function validHost(host) {
if (!/^[A-Za-z0-9_.:@\-\[\]]+$/.test(host ?? '')) fail('AIA_DEPLOY_HOST is invalid'); return typeof host === 'string' && /^[A-Za-z0-9_.:@\-\[\]]+$/.test(host);
if (!identity || !path.isAbsolute(identity)) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path'); }
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']; return ['ssh', '-i', identity, '-o', 'IdentitiesOnly=yes'];
} }
function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); } function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); }
@@ -99,11 +117,6 @@ function assertPreflight({ requirePrimary = true } = {}) {
for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`); for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`);
return sha; return sha;
} }
async function payloadFrom(file) {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) fail('Portal ciphertext must be a bounded regular private file');
return validatePortalPayload(JSON.parse(await fs.readFile(file, 'utf8')));
}
async function syntheticPayload(directory) { async function syntheticPayload(directory) {
const payload = { alg: portalAlgorithm, streams: [ const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'), await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'),
@@ -113,6 +126,12 @@ async function syntheticPayload(directory) {
await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 }); await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 });
return { file, payload, synthetic: true }; 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) { async function createSourceSnapshot(directory, sourceCommit) {
const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean); const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean);
const included = [], excluded = []; const included = [], excluded = [];
@@ -144,26 +163,58 @@ function validationEnvironment() {
async function executeBuild(buildEnv) { async function executeBuild(buildEnv) {
const testEnv = validationEnvironment(); const testEnv = validationEnvironment();
run('npm', ['run', 'check'], { env: testEnv }); run('npm', ['run', 'check'], { env: testEnv });
run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs'], { env: testEnv }); run('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', 'test:publication'], { env: testEnv });
run('npm', ['run', 'audit:links'], { env: testEnv }); run('npm', ['run', 'audit:links'], { env: testEnv });
run('npm', ['run', 'audit:images'], { env: testEnv }); run('npm', ['run', 'audit:images'], { env: testEnv });
run('npm', ['run', 'audit:blog'], { env: buildEnv }); run('npm', ['run', 'audit:blog'], { env: buildEnv });
run('npm', ['run', 'build'], { env: buildEnv }); run('npm', ['run', 'build'], { env: buildEnv });
} }
function parseArgs(argv) { export function parseArgs(argv) {
const allowed = new Set(['--dry-run', '--confirm']); const allowed = new Set(['--dry-run', '--confirm']);
if (argv.some((arg) => !allowed.has(arg))) fail('Usage: npm run deploy -- --dry-run | npm run deploy -- --confirm'); const flags = argv.filter((arg) => arg.startsWith('--'));
if (argv.includes('--dry-run') && argv.includes('--confirm')) fail('Choose --dry-run or --confirm, not both'); const positional = argv.filter((arg) => !arg.startsWith('--'));
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') }; 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 };
} }
async function remoteDeploy({ host, identity, sha, source, dist, manifest }) { export function deploymentInputs(options, env = process.env) {
const [ssh, ...sshOptions] = sshTarget(host, identity); 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 sourceStage = `${projectRoot}/staging/source-${sha}`;
const distStage = `${projectRoot}/staging/dist-${sha}`; const distStage = `${projectRoot}/staging/dist-${sha}`;
const remoteManifest = `/root/aia-site-${sha}.manifest.json`; const remoteManifest = `/root/aia-site-${sha}.manifest.json`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]); run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]);
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`; const transport = rsyncTransport([ssh, ...sshOptions]);
const transfer = (local, remote, remove) => { const transfer = (local, remote, remove) => {
const args = rsyncArguments(local, remote, { remove }); const args = rsyncArguments(local, remote, { remove });
args.splice(-2, 0, '-e', transport); args.splice(-2, 0, '-e', transport);
@@ -177,17 +228,18 @@ async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
const inputs = deploymentInputs(options);
const sha = assertPreflight({ requirePrimary: !options.dryRun }); const sha = assertPreflight({ requirePrimary: !options.dryRun });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-')); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
try { try {
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE; const portal = await portalInput(inputs.ciphertext, temporary);
const portal = configured ? { file: configured, payload: await payloadFrom(configured), synthetic: false } : await syntheticPayload(temporary); if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE or a positional ciphertext path');
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification'); if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'off', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' }; 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); await executeBuild(env);
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload))); 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 }); 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('; ')}`); if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`);
const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha); const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha);
const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`); const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`);
@@ -195,7 +247,7 @@ async function main() {
const snapshot = await createSourceSnapshot(temporary, sha); 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 }; 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; } if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2)); return; }
await remoteDeploy({ host: process.env.AIA_DEPLOY_HOST, identity: process.env.AIA_DEPLOY_SSH_IDENTITY, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile }); await remoteDeploy({ ...inputs, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile });
console.log(JSON.stringify({ promoted: true, ...summary }, null, 2)); console.log(JSON.stringify({ promoted: true, ...summary }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); } } finally { await fs.rm(temporary, { recursive: true, force: true }); }
} }
+46 -1
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import test from 'node:test'; import test from 'node:test';
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs'; 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 fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')); const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
@@ -42,3 +42,48 @@ test('deployment transfer uses the Contabo ownership and mode interface', () =>
assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']); assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']);
assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/); 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 }); }
});
+126 -66
View File
@@ -1,11 +1,15 @@
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */ /** Owner-run private portal preparation. Access codes are accepted only by hidden TTY prompts. */
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs'; import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { applyRoleMappings, assertProductionMappings, manifestDigest, payloadDigest, summarizeMappings } from './portal-production.mjs';
const defaultRoot = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || defaultRoot);
const timestamp = () => new Date().toISOString().replaceAll(':', '-');
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
async function hiddenPrompt(label) { async function hiddenPrompt(label) {
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required'); if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
process.stderr.write(label); process.stderr.write(label);
@@ -24,67 +28,123 @@ async function hiddenPrompt(label) {
process.stdin.on('data', onData); process.stdin.on('data', onData);
}); });
} }
async function main() { async function readJson(file) { return JSON.parse(await fs.readFile(file, 'utf8')); }
const [command, source, ...extra] = process.argv.slice(2); async function atomicPrivateJson(file, value) {
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt'); const temporary = `${file}.tmp-${process.pid}`;
// No scanning for credentials. The original is read only; never copied into Git. await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8'))); await fs.chmod(temporary, 0o600);
await fs.mkdir(root, { recursive: true, mode: 0o700 }); await fs.rename(temporary, file);
await fs.chmod(root, 0o700);
const manifestPath = path.join(root, 'manifest.json');
let previous = { resources: [] };
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
const nextStreams = [], unlocked = [];
let rewritten = 0;
for (let index = 0; index < payload.streams.length; index++) {
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
const stream = payload.streams[index];
let html = await decryptPortalStream(stream, pass);
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
// Reject identical credentials across views to preserve stream separation.
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
for (const ref of resourceReferences(html, stream.id)) {
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
record.references.push(ref); resources.set(ref.sourceUrl, record);
}
if (command === 'rewrite') {
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id)));
html = result.html; rewritten += result.count;
const next = await encryptPortalStream(stream.id, html, pass);
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
nextStreams.push(next);
}
unlocked.push(stream.id); html = ''; pass = '';
}
const stamp = new Date().toISOString().replaceAll(':', '-');
if (command === 'collect') {
const approvedOrigins = Array.isArray(previous.approvedOrigins)
? previous.approvedOrigins.filter((origin) => {
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; }
catch { return false; }
})
: undefined;
for (const [url, record] of resources) {
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
}
const manifest = {
version: 1,
updated: stamp,
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
views: unlocked,
resources: [...resources.values()],
};
// Retain each previous manifest; no original evidence is overwritten.
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; }
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
} else {
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
}
const list = [...resources.values()];
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2));
} }
main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; }); 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);
}
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; });
+28 -6
View File
@@ -85,20 +85,25 @@ function formAAdapters(html) {
return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]); return [...html.matchAll(/<section\b[^>]*\bdata-jotform-form-a\b[^>]*>/gi)].map((match) => match[0]);
} }
function unsafeSignals(html, allowApprovedFormA = false) { function unsafeSignals(html, allowApprovedFormA = false, allowApprovedCounters = false) {
const signals = []; const signals = [];
if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe'); if (!allowApprovedFormA && /<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier'); if (!allowApprovedFormA && /262946078314059/i.test(html)) signals.push('disabled Form A identifier');
if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script'); if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script');
if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode'); if (!allowApprovedFormA && /data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode');
if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin'); const counterReviewed = allowApprovedCounters
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content'); ? html.replace(/<div\b[^>]*\bdata-counter\b[^>]*>/gi, (tag) =>
tag.replace('data-api-base="https://api.azinstitute4autism.com"', ''))
: html;
if (/api\.azinstitute4autism\.com/i.test(counterReviewed)) signals.push('production API origin');
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE\sKEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content');
return signals; return signals;
} }
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }) { export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }, counters = 'off') {
if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.'); if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.');
if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.'); if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
if (!['live', 'off', 'mock'].includes(counters)) throw new Error('Artifact counters must be live, off, or mock.');
if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.'); if (!portal || !['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.'); if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.');
const formAEnabled = forms === 'live'; const formAEnabled = forms === 'live';
@@ -115,6 +120,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
let secretSignals = []; let secretSignals = [];
let formAIframes = 0; let formAIframes = 0;
let deferredFormAIframes = 0; let deferredFormAIframes = 0;
let counterInstances = 0;
for (const file of htmlFiles) { for (const file of htmlFiles) {
const html = await readText(file); const html = await readText(file);
@@ -127,7 +133,20 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash); const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined; const expectedFormAUrl = formAEnabled ? staticFormAUrls.get(route) : undefined;
const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined; const expectedDeferredFormAUrl = formAEnabled ? deferredFormAUrls.get(route) : undefined;
const signals = unsafeSignals(html, formAEnabled); const signals = unsafeSignals(html, formAEnabled, counters === 'live');
const countersOnPage = [...html.matchAll(/<div\b[^>]*\bdata-counter\b[^>]*>/gi)].map((match) => match[0]);
for (const counter of countersOnPage) {
if (counters === 'live') {
if (!counter.includes('data-counter-mode="live"')
|| !counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
errors.push(`${route}: live counter lacks the approved mode or API origin`);
}
} else if (counter.includes('data-counter-mode="live"')
|| counter.includes('data-api-base="https://api.azinstitute4autism.com"')) {
errors.push(`${route}: live counter appears in a ${counters} artifact`);
}
}
counterInstances += countersOnPage.length;
const jotformFrames = iframeSources(html).filter((src) => { const jotformFrames = iframeSources(html).filter((src) => {
try { try {
return new URL(src).origin === 'https://form.jotform.com'; return new URL(src).origin === 'https://form.jotform.com';
@@ -212,6 +231,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index'); if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index');
} }
if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`); if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`);
if (counters === 'live' && counterInstances === 0) errors.push('live artifact contains no article counters');
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`); if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort(); const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
@@ -241,6 +261,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live', por
return { return {
mode, mode,
forms, forms,
counters,
portal: portal.mode, portal: portal.mode,
htmlRoutes: htmlFiles.length, htmlRoutes: htmlFiles.length,
sitemapUrls: sitemapRoutes.length, sitemapUrls: sitemapRoutes.length,
@@ -264,7 +285,8 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live'; const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none'; 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 portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length);
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 }); const counters = process.argv.find((arg) => arg.startsWith('--counters='))?.slice('--counters='.length) ?? 'off';
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 }, counters);
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
if (!result.passed) process.exitCode = 1; if (!result.passed) process.exitCode = 1;
} }
+14
View File
@@ -110,6 +110,19 @@ test('artifact rejects production API origin even when Form A is approved', asyn
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals'))); assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
}); });
test('live counters allow only the reviewed API origin and explicit live mode', async () => {
const root = await fixture('indexing');
await fs.appendFile(path.join(root, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://api.azinstitute4autism.com"></div>');
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'live')).passed, true);
assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'none' }, 'off')).passed, false);
const wrong = await fixture('indexing');
await fs.appendFile(path.join(wrong, 'index.html'), '<div data-counter data-counter-mode="live" data-api-base="https://other.invalid"></div>');
const result = await verifyArtifact(wrong, 'indexing', 'live', { mode: 'none' }, 'live');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('approved mode or API origin')));
});
test('artifact rejects Form A identity outside the exact approved HTML routes', async () => { test('artifact rejects Form A identity outside the exact approved HTML routes', async () => {
const root = await fixture('staging'); const root = await fixture('staging');
await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact'); await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact');
@@ -182,4 +195,5 @@ test('artifact rejects extra routes, wrong origins and prematurely loaded drawer
test('obsolete artifact modes are rejected with separate publication/form guidance', async () => { test('obsolete artifact modes are rejected with separate publication/form guidance', async () => {
await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/); await assert.rejects(verifyArtifact('/unused','isolated-test'), /--forms=live\|off\|mock/);
await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/); await assert.rejects(verifyArtifact('/unused','staging','invalid'), /live, off, or mock/);
await assert.rejects(verifyArtifact('/unused','staging','live',{ mode: 'none' },'invalid'), /counters must be live/);
}); });
+12 -7
View File
@@ -20,7 +20,7 @@ test('counter configuration defaults off and requires explicit approval', () =>
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off'); assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'false').mode, 'off');
assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live'); assert.equal(resolveCounterConfig('live', 'https://api.example.test', 'true').mode, 'live');
assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off'); assert.equal(resolveCounterConfig('live', 'http://api.example.test', 'true').mode, 'off');
assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'off'); assert.equal(counterModeForRuntime(resolveCounterConfig('live', 'https://api.example.test', 'true')), 'live');
assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock'); assert.equal(resolveCounterConfig('mock', undefined, undefined).mode, 'mock');
}); });
@@ -73,10 +73,15 @@ test('inactive source has no native form or live vendor destination', () => {
assert.match(sources, /data-form-mode/); assert.match(sources, /data-form-mode/);
}); });
test('counter source has no implicit production fallback or credentialed fetch', () => { test('counter source requires explicit live configuration and omits credentials', () => {
const source = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8'); const component = fs.readFileSync('src/components/LikeViewCounter.astro', 'utf8');
assert.doesNotMatch(source, /api\.azinstitute4autism\.com/); const runtime = fs.readFileSync('src/scripts/likes-views.ts', 'utf8');
assert.doesNotMatch(source, /credentials:\s*['"]include/); const deploy = fs.readFileSync('tools/deploy.mjs', 'utf8');
assert.doesNotMatch(source, /method:\s*['"](?:POST|DELETE)/); assert.doesNotMatch(component, /api\.azinstitute4autism\.com/);
assert.match(source, /data-counter-mode/); assert.doesNotMatch(runtime, /credentials:\s*['"]include/);
assert.match(runtime, /credentials:\s*['"]omit/);
assert.match(component, /data-counter-mode/);
assert.match(deploy, /PUBLIC_COUNTERS_MODE:\s*'live'/);
assert.match(deploy, /PUBLIC_COUNTERS_LIVE_APPROVED:\s*'true'/);
assert.match(deploy, /PUBLIC_AIA_API_BASE:\s*'https:\/\/api\.azinstitute4autism\.com'/);
}); });
+127
View File
@@ -0,0 +1,127 @@
/** Private employee-portal mapping and production-readiness helpers.
* These helpers deliberately never decrypt portal streams or read access codes.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const internal = new Set(['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL']);
const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..';
const hash = (value) => createHash('sha256').update(value).digest('hex');
export const payloadDigest = (payload) => hash(Buffer.from(JSON.stringify(validatePortalPayload(payload))));
export const manifestDigest = (manifest) => hash(Buffer.from(JSON.stringify(manifest)));
function roleForClassification(classification) {
if (classification === 'STAFF-INTERNAL') return 'staff';
if (classification === 'MANAGEMENT-INTERNAL') return 'management';
return null;
}
function safeDeliveryPath(role, value) {
const prefix = `/${role}-resources/`;
if (typeof value !== 'string' || !value.startsWith(prefix)) return false;
const name = value.slice(prefix.length);
return safeName(name) && value === `${prefix}${name}`;
}
export function mappingsFor(record) {
if (Array.isArray(record.installMappings)) return record.installMappings;
const role = roleForClassification(record.classification);
return role ? [{
role,
classification: record.classification,
deliveryPath: record.deliveryPath,
mappingApproved: record.mappingApproved,
accessPolicyApproved: record.accessPolicyApproved,
rewriteApproved: record.rewriteApproved,
destinationInstalled: record.destinationInstalled,
destinationHashVerified: record.destinationHashVerified,
destinationPermissionsVerified: record.destinationPermissionsVerified,
destinationAccessVerified: record.destinationAccessVerified,
}] : [];
}
export function approvedMapping(record, role) {
const mapping = mappingsFor(record).find((candidate) => candidate.role === role);
if (!mapping || !internal.has(mapping.classification) || !safeDeliveryPath(role, mapping.deliveryPath)) return null;
if (!mapping.mappingApproved || !mapping.accessPolicyApproved || !mapping.rewriteApproved) return null;
return mapping;
}
export function deriveDestinationName(record) {
let originalPath = record.originalPath;
try { originalPath ||= new URL(record.sourceUrl).pathname; } catch { return null; }
const extension = path.extname(originalPath).toLowerCase();
if (!/^[.][a-z0-9]{1,10}$/.test(extension) || !safeName(record.localFile)) return null;
return `${record.localFile}${extension}`;
}
export function summarizeMappings(manifest, streamRoles) {
if (!streamRoles || !streamRoles.staff || !streamRoles.management || streamRoles.staff === streamRoles.management) throw new Error('A distinct staff/management stream-role mapping is required');
const summary = { staffOnly: 0, managementOnly: 0, shared: 0, ambiguous: 0, proposed: [] };
for (const record of manifest.resources || []) {
const views = new Set((record.references || []).map((reference) => reference.view));
const roles = new Set([...views].map((view) => view === streamRoles.staff ? 'staff' : view === streamRoles.management ? 'management' : null));
if (roles.has(null) || !roles.size || !deriveDestinationName(record)) { summary.ambiguous++; continue; }
if (roles.size === 2) summary.shared++; else if (roles.has('staff')) summary.staffOnly++; else summary.managementOnly++;
summary.proposed.push({ sourceUrl: record.sourceUrl, localFile: record.localFile, roles: [...roles].sort(), filename: deriveDestinationName(record) });
}
return summary;
}
export function applyRoleMappings(manifest, streamRoles, { approveShared = false } = {}) {
const summary = summarizeMappings(manifest, streamRoles);
const resources = (manifest.resources || []).map((record) => {
const proposal = summary.proposed.find((entry) => entry.sourceUrl === record.sourceUrl);
if (!proposal || (proposal.roles.length > 1 && !approveShared)) return { ...record };
const installMappings = proposal.roles.map((role) => ({
role,
classification: role === 'staff' ? 'STAFF-INTERNAL' : 'MANAGEMENT-INTERNAL',
deliveryPath: `/${role}-resources/${proposal.filename}`,
mappingApproved: true,
accessPolicyApproved: true,
rewriteApproved: true,
// Server-installation evidence is intentionally separate and remains false.
destinationInstalled: false,
destinationHashVerified: false,
destinationPermissionsVerified: false,
destinationAccessVerified: false,
}));
return {
...record,
references: (record.references || []).map((reference) => ({
...reference,
role: reference.view === streamRoles.staff ? 'staff' : reference.view === streamRoles.management ? 'management' : undefined,
})),
classification: installMappings.length === 1 ? installMappings[0].classification : 'UNRESOLVED',
installMappings,
mappingStatus: installMappings.length === 1 ? 'approved-for-installation' : 'approved-shared-role-installation',
};
});
return { ...manifest, resources, mappingSchema: 2, mappingUpdatedAt: new Date().toISOString() };
}
export function assertProductionMappings(manifest) {
let count = 0;
for (const record of manifest.resources || []) {
const references = record.references || [];
if (!references.length) continue;
const roles = new Set(references.map((ref) => ref.role).filter(Boolean));
// Role labels are only required after the private stream-role step.
if (!roles.size) throw new Error('Private manifest lacks verified stream-to-role references');
for (const role of roles) {
if (!approvedMapping(record, role)) throw new Error('Private manifest has an unresolved or unapproved protected resource mapping');
count++;
}
}
if (!count) throw new Error('Private manifest has no approved protected resource mappings');
return count;
}
export async function readPrivateJson(file, label) {
const stat = await fs.lstat(file).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) throw new Error(`${label} is unavailable or unsafe`);
return JSON.parse(await fs.readFile(file, 'utf8'));
}
export async function assertProductionReadiness(privateRoot, payloadFile) {
const payload = validatePortalPayload(await readPrivateJson(payloadFile, 'Portal ciphertext'));
const manifest = await readPrivateJson(path.join(privateRoot, 'manifest.json'), 'Private portal manifest');
const readiness = await readPrivateJson(path.join(privateRoot, 'production-readiness.json'), 'Private production readiness record');
if (readiness.schema !== 1 || readiness.payloadSha256 !== payloadDigest(payload) || readiness.manifestSha256 !== manifestDigest(manifest)) throw new Error('Production portal readiness record does not match the current private ciphertext and manifest');
const mappings = assertProductionMappings(manifest);
if (readiness.protectedMappings !== mappings || readiness.legacyHubfsReferences !== 0) throw new Error('Production portal readiness record does not prove complete protected-link rewriting');
return { payload, manifest, readiness };
}
+8 -6
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { approvedMapping } from './portal-production.mjs';
export const sourceOrigin = 'https://www.azinstitute4autism.com'; export const sourceOrigin = 'https://www.azinstitute4autism.com';
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -28,16 +29,17 @@ export function resourceReferences(html, view) {
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url'); for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
return references; return references;
} }
export function rewriteResources(html, records) { export function rewriteResources(html, records, role) {
const mappings = new Map(); const mappings = new Map();
for (const record of records) { for (const record of records) {
if (!record.rewriteApproved) continue; const mapping = role ? approvedMapping(record, role) : null;
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping'); // Public links use their legacy, explicitly approved single-path contract.
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping'); const publicMapping = record.classification === 'PUBLIC-APPROVED' && record.rewriteApproved && record.mappingApproved && typeof record.deliveryPath === 'string' && record.deliveryPath.startsWith('/') && !record.deliveryPath.startsWith('//') && !/[?<>"'\s]/.test(record.deliveryPath);
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control'); if (!mapping && !publicMapping) continue;
const deliveryPath = mapping?.deliveryPath ?? record.deliveryPath;
for (const ref of record.references) { for (const ref of record.references) {
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review'); if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
mappings.set(ref.original, record.deliveryPath); mappings.set(ref.original, deliveryPath);
} }
} }
let count = 0; let count = 0;
+72 -9
View File
@@ -16,6 +16,8 @@ import { setTimeout as delay } from 'node:timers/promises';
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live'; const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
assert.ok(['live', 'off', 'mock'].includes(forms)); assert.ok(['live', 'off', 'mock'].includes(forms));
const browserName = process.argv.find(a => a.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
@@ -33,7 +35,7 @@ const routes = [
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' }, { route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
]; ];
const widths = [320, 390, 768, 1440]; const widths = [320, 390, 768, 1440];
const evidence = { forms, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' }; const evidence = { forms, browserName, routes, widths, checks: 0, blocked: [], jotformAttempts: [], resourceRequests: [], screenshots: [], fallbackChecks: [], status: 'failed' };
const reserve = net.createServer(); const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port; const port = reserve.address().port;
@@ -47,9 +49,11 @@ function expectedUrl(locale) {
} }
try { try {
const { chromium } = await import(moduleName); const drivers = await import(moduleName);
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE const driver = drivers[browserName];
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath
? { headless: true, executablePath }
: { headless: true }); : { headless: true });
evidence.browser = browser.version(); evidence.browser = browser.version();
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
@@ -70,17 +74,38 @@ try {
const duration = 30 * 86400000; const duration = 30 * 86400000;
const staffCode = randomBytes(24).toString('hex'); const staffCode = randomBytes(24).toString('hex');
const managementCode = randomBytes(24).toString('hex'); const managementCode = randomBytes(24).toString('hex');
const staffResourceName = 'a'.repeat(64) + '.pdf';
const managementResourceName = 'b'.repeat(64) + '.docx';
const staffResourcePath = `/staff-resources/${staffResourceName}`;
const managementResourcePath = `/management-resources/${managementResourceName}`;
const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>'; const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>';
const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>'; const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>';
const payload = { alg: portalAlgorithm, streams: [ const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">' + styledFixture + staffTemplates, staffCode), await encryptPortalStream('b', `<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="${staffResourcePath}">Staff's Safety &amp; Training: Résumé (2026)!</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">` + styledFixture + staffTemplates, staffCode),
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode), await encryptPortalStream('a', `<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="${managementResourcePath}">Management – Policies / Q&amp;A #1</a>` + styledFixture, managementCode),
] }; ] };
const expectedAuthorization = {
staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'),
management: 'Basic ' + Buffer.from('management:' + managementCode, 'utf8').toString('base64'),
};
const guard = async (context, inject = false) => { const guard = async (context, inject = false) => {
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); }); await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
await context.route('**/*', async (route) => { await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url()); const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) { if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
const resource = /^\/(staff|management)-resources\/([a-f0-9]{64})[.](pdf|docx)$/.exec(url.pathname);
if (resource) {
const role = resource[1];
const authorized = req.headers().authorization === expectedAuthorization[role];
evidence.resourceRequests.push({ role, authorized });
if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } });
return route.fulfill({
status: 200,
contentType: resource[3] === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'cache-control': 'private, no-store' },
body: resource[3] === 'pdf' ? `%PDF-1.7\nSynthetic ${role} document\n` : `Synthetic ${role} document\n`,
});
}
if (inject && url.pathname === '/employee-portal') { if (inject && url.pathname === '/employee-portal') {
const response = await route.fetch(); const response = await route.fetch();
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2'); const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
@@ -327,14 +352,14 @@ try {
await input.fill(staffCode); await input.press('Enter'); await input.fill(staffCode); await input.press('Enter');
await p.locator('[data-portal-content]').waitFor({ state: 'visible' }); await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Synthetic Management|Management fixture only/);
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined); assert.equal(await p.evaluate(() => window.portalUnsafe), undefined);
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0); assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true); assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
assert.equal(await input.inputValue(), ''); assert.equal(await input.inputValue(), '');
const checkPresentation = async (staff = false) => { const checkPresentation = async (staff = false) => {
const content = p.locator('[data-portal-content]'); const content = p.locator('[data-portal-content]');
assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 3); assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 4);
assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0); assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0);
assert.equal(await content.locator('a[href^="javascript:"]').count(), 0); assert.equal(await content.locator('a[href^="javascript:"]').count(), 0);
assert.equal(await content.locator('th[scope="col"]').count(), 2); assert.equal(await content.locator('th[scope="col"]').count(), 2);
@@ -346,7 +371,7 @@ try {
assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)'); assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)');
assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)'); assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)');
assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28)); assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28));
assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)'); assert.equal(await content.locator('.portal-contact-link').first().evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
await button.focus(); await button.focus();
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
@@ -361,6 +386,26 @@ try {
assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count'); assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count');
assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button'); assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button');
assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link'); assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link');
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + staffResourcePath), 401);
await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const staffLink = staffContent.getByRole('link', { name: "Staff's Safety & Training: Résumé (2026)!" });
const staffDownload = p.waitForEvent('download');
await staffLink.click();
assert.equal((await staffDownload).suggestedFilename(), 'Staffs-Safety-and-Training-Resume-2026.pdf');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Staffs-Safety-and-Training-Resume-2026.pdf.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
assert.equal(p.url(), origin + '/employee-portal');
const beforeCrossRole = evidence.resourceRequests.length;
await staffLink.evaluate((link, href) => { link.href = href; }, managementResourcePath);
await staffLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeCrossRole, 'staff credential is not sent to management resources');
assert.deepEqual(await p.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } })), { local: {}, session: {} });
assert.equal(p.url().includes(staffCode), false);
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-synthetic-' + width + '.png'); evidence.screenshots.push('portal-synthetic-' + width + '.png');
@@ -370,6 +415,24 @@ try {
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
await checkPresentation(); await checkPresentation();
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + managementResourcePath), 401);
await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const managementLink = p.getByRole('link', { name: 'Management – Policies / Q&A #1' });
const managementDownload = p.waitForEvent('download');
await managementLink.click();
assert.equal((await managementDownload).suggestedFilename(), 'Management-Policies-Q-and-A-1.docx');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Management-Policies-Q-and-A-1.docx.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
const beforeManagementCrossRole = evidence.resourceRequests.length;
await managementLink.evaluate((link, href) => { link.href = href; }, staffResourcePath);
await managementLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeManagementCrossRole, 'management credential is not sent to staff resources');
assert.equal(p.url().includes(managementCode), false);
assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout'); assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout');
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-management-synthetic-' + width + '.png'); evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
+87 -3
View File
@@ -10,6 +10,15 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { isFormACompletion } from '../src/utils/jotform-completion.ts'; import { isFormACompletion } from '../src/utils/jotform-completion.ts';
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts'; import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
import {
PortalDownloadError,
fetchProtectedPortalResource,
portalDownloadErrorMessage,
portalRoleForStreamId,
protectedPortalResource,
recognizableDownloadFilename,
} from '../src/utils/portal-download.mjs';
import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs';
test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => { test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => {
const source = {}; const source = {};
@@ -47,6 +56,65 @@ test('portal presentation translates only explicit button tokens/brand fills and
assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link'); assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link');
}); });
test('portal downloads use only the validated role and an in-memory authorization header', async () => {
assert.equal(portalRoleForStreamId('b'), 'staff');
assert.equal(portalRoleForStreamId('a'), 'management');
assert.equal(portalRoleForStreamId('fixture-staff'), undefined);
const origin = 'https://www.azinstitute4autism.com';
const staff = protectedPortalResource('/staff-resources/' + 'a'.repeat(64) + '.pdf', origin, "Staff's Safety & Training: Résumé (2026)!");
const management = protectedPortalResource('/management-resources/' + 'b'.repeat(64) + '.docx', origin, 'Management – Policies / Q&A #1');
assert.deepEqual(staff, {
role: 'staff', filename: 'a'.repeat(64) + '.pdf',
downloadFilename: 'Staffs-Safety-and-Training-Resume-2026.pdf',
url: origin + '/staff-resources/' + 'a'.repeat(64) + '.pdf',
});
assert.equal(management.downloadFilename, 'Management-Policies-Q-and-A-1.docx');
assert.equal(recognizableDownloadFilename('Client Check-In & Check-Out.xlsx', 'c'.repeat(64) + '.xlsx'), 'Client-Check-In-and-Check-Out.xlsx');
assert.equal(recognizableDownloadFilename('../../', 'd'.repeat(64) + '.pdf'), 'AIA-document.pdf');
assert.equal(recognizableDownloadFilename('No server extension', 'e'.repeat(64)), undefined);
assert.equal(protectedPortalResource('/staff-resources/nested/file.pdf', origin), undefined);
assert.equal(protectedPortalResource('/staff-resources/file.pdf?code=forbidden', origin), undefined);
assert.equal(protectedPortalResource('https://other.invalid/staff-resources/file.pdf', origin), undefined);
const code = 'synthetic browser memory only';
let captured;
const result = await fetchProtectedPortalResource(staff, { role: 'staff', code }, async (url, options) => {
captured = { url, options };
return new Response('synthetic document', {
// nginx may expose the hash name; the approved label remains authoritative.
headers: { 'content-disposition': `attachment; filename="${staff.filename}"` },
});
});
assert.equal(captured.url, staff.url);
assert.equal(captured.url.includes(code), false);
assert.equal(captured.options.method, 'GET');
assert.equal(captured.options.credentials, 'omit');
assert.equal(captured.options.cache, 'no-store');
assert.equal(captured.options.redirect, 'error');
assert.equal(captured.options.referrerPolicy, 'no-referrer');
assert.match(captured.options.headers.Authorization, /^Basic /);
assert.equal(result.filename, 'Staffs-Safety-and-Training-Resume-2026.pdf');
assert.equal(await result.blob.text(), 'synthetic document');
for (const [resource, credential] of [
[management, { role: 'staff', code }],
[staff, { role: 'management', code }],
]) {
let called = false;
await assert.rejects(
fetchProtectedPortalResource(resource, credential, async () => { called = true; return new Response(); }),
(error) => error instanceof PortalDownloadError && error.code === 'role',
);
assert.equal(called, false, 'cross-role credentials are never transmitted');
}
await assert.rejects(
fetchProtectedPortalResource(staff, { role: 'staff', code }, async () => new Response('', { status: 401 })),
(error) => error instanceof PortalDownloadError && error.code === 'unauthorized',
);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('unauthorized')), /session could not authorize/);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('role')), /not available from this portal/);
});
test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => { test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => {
const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8'); const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8');
const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8'); const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8');
@@ -108,12 +176,28 @@ test('resource extraction reconciles duplicate views and rewrites only approved
const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>'; const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>';
const refs = resourceReferences(html, 'synthetic'); const refs = resourceReferences(html, 'synthetic');
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1); assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' }; const record = { references: refs, rewriteApproved: true, mappingApproved: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
assert.throws(() => rewriteResources(html, [record])); assert.equal(rewriteResources(html, [record], 'staff').html, html);
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]); const result = rewriteResources(html, [{ ...record, accessPolicyApproved: true }], 'staff');
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form')); assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
assert.equal(rewriteResources(html, []).html, html); assert.equal(rewriteResources(html, []).html, html);
}); });
test('role-based resource mapping separates source approval from later server verification', () => {
const record = {
sourceUrl: 'https://www.azinstitute4autism.com/hubfs/guide.pdf', originalPath: '/hubfs/guide.pdf',
localFile: 'a'.repeat(64), references: [{ view: 'staff-stream' }, { view: 'management-stream' }],
};
const roles = { staff: 'staff-stream', management: 'management-stream' };
const summary = summarizeMappings({ resources: [record] }, roles);
assert.equal(summary.shared, 1); assert.equal(summary.ambiguous, 0);
const mapped = applyRoleMappings({ resources: [record] }, roles, { approveShared: true });
assert.equal(mapped.resources[0].installMappings.length, 2);
for (const role of ['staff', 'management']) {
const mapping = approvedMapping(mapped.resources[0], role);
assert.ok(mapping); assert.equal(mapping.destinationInstalled, false);
assert.equal(mapping.destinationAccessVerified, false);
}
});
test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => { test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-')); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
try { try {