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