Course Map Export Accessibility Audit (#1153)
The two HTML documents a user can download from a course map — the interactive
standalone page (export.html) and the source document Puppeteer prints to PDF
(export.pdf) — are audited for WCAG 2.1 AA by the existing converter audit
engine, not a second axe-core pipeline. The converter API already runs in the
accessible-pdf-converter-api-node-* containers on the shared 10.1.1.4 host;
this gate POSTs each rendered document to its /api/audit endpoint as a
build-artifact audit and asserts a clean AA pass.
How it works
src/services/export-a11y.tsbuildExportArtifacts(map, opts)— renders both downloadable HTML documents (pure; no network). CSV/JSON exports are data, not documents, so they are out of scope.auditCourseMapExports(client, map, opts)— audits both concurrently and aggregates the verdicts (passedis true only when every artifact passes).
src/services/audit-client.ts— thin client for/api/audit: submit abuild-artifactaudit, poll the async job, and read a pass/fail verdict offaudit_v1.summary(severity counts +passes). The summary is populated on both the free and pro tiers, so a pass/fail gate works with a free-tier key; a pro-tier key additionally fills the per-violation detail used to explain a failure.- The gate is the vitest spec
src/__tests__/services/export-a11y.test.ts. Its livedescribeblock isskipIfthe audit env is unset, so it runs only where the engine is reachable and skips cleanly in plain GitHub CI (which has no route to the internal container).
Configuration
All optional — when COURSE_MAP_AUDIT_URL/COURSE_MAP_AUDIT_KEY are unset the
worker boots and serves exports normally and the gate is skipped.
| Env var | Example | Notes |
|---|---|---|
COURSE_MAP_AUDIT_URL | http://api-node-1:8790 | Converter API base URL. On 10.1.1.4 the engine is reachable internally at http://api-node-1:8790; the public route is https://api.theaccessible.org. |
COURSE_MAP_AUDIT_KEY | cmap_… | Org-scoped audit key (see below). Shown once at mint time; only its SHA-256 hash is stored. |
COURSE_MAP_AUDIT_REPO | theaccessible/course-map | Must match the key’s repo_scope, or be anything for an org-level key. Defaults to theaccessible/course-map. |
Provisioning a key
The converter authenticates /api/audit with a key stored as a SHA-256 hash in
the converter Supabase’s audit_api_keys table. Mint one without touching
any database:
cd workers/course-map-apinpm run audit:mint-key -- --org <converter-org-uuid> --tier pro# org-level key (accepts any repo): add --repo ""The script prints (1) the secret to set as COURSE_MAP_AUDIT_KEY and (2) a
ready-to-run INSERT for the converter Supabase SQL editor. Run the INSERT
there (DB changes are applied manually), then set the secret on the course-map
worker.
--tier freeis enough for the pass/fail gate (severity counts only).--tier proadds per-violation detail (rule, WCAG criterion, selector) that makes a failing gate actionable.- Each audit run costs 1 credit against the org; the gate runs two audits per invocation.
Running the gate
cd workers/course-map-apiCOURSE_MAP_AUDIT_URL=http://api-node-1:8790 \COURSE_MAP_AUDIT_KEY=cmap_… \npx vitest run src/__tests__/services/export-a11y.test.tsRun it from a container/host on 10.1.1.4 (or against the public URL) so the
engine is reachable. Without the env vars, the live block is skipped and a
named placeholder test records why.
Why a skippable test, not a hard GitHub CI gate
The engine runs async (SQS → batch worker) and its internal URL is only
reachable from the docker host. A hard gate in GitHub Actions would either
break the test job (no network to the container) or hit the public prod engine
on every push (credits + nondeterminism). A skipIf integration test runs
wherever the engine is reachable — locally, on the server, or in a future
self-hosted CI runner on 10.1.1.4 — and no-ops elsewhere. The network-free
unit tests of the artifact builder and client verdict logic give the gate real
coverage in plain CI regardless.