diff --git a/.env.example b/.env.example index e8a1267..490ef59 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,30 @@ # Public HTTPS origin in production. It must exactly match the browser origin. PRODUCTION_URL=http://localhost:5102 +# Authentication is disabled by default. To enable it, set true and configure +# allowed email domains, a JWT secret, and SMTP below. +AUTH_REQUIRE_LOGIN=false +ALLOWED_DOMAINS=example.org +JWT_SECRET= +ADMIN_EMAILS=admin@example.org + +EMAIL_PROVIDER=mailgun +SMTP_HOST=smtp.mailgun.org +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=AccessForge + # Hostnames that URL-audit endpoints may fetch, comma-separated. -# Use customer-approved public hosts; private/reserved IPs remain blocked. +# Empty disables URL audits. Private/reserved IPs remain blocked. ANALYZE_URL_ALLOWED_HOSTS=example.org,www.example.org +# Optional GraphQL endpoint used only by the CLI publist command. +PUBLIST_GRAPHQL_ENDPOINT= + +# Optional link to an organization-specific accessibility standard. +IITAA_URL= + # Optional higher rate-limit tier for trusted automation. # Generate with: openssl rand -hex 32 API_PRIVILEGED_TOKEN= @@ -12,4 +32,3 @@ API_PRIVILEGED_TOKEN= # Optional host port overrides. ACCESSFORGE_WEB_PORT=5102 ACCESSFORGE_API_PORT=5103 - diff --git a/PRODUCT.md b/PRODUCT.md index 0944dc0..2ca2820 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -82,7 +82,7 @@ Monthly monitoring is successful when the customer can identify new, fixed, regr ## Near-term roadmap -1. Remove the remaining upstream ICJIA-specific defaults and make authentication/allowed domains environment-configurable. +1. Validate the baseline-audit offer with three paying customers and record delivery time, false-positive rate, and repeat-purchase intent. 2. Add a monitored-target model with dated baselines and explicit scan-to-scan diffs. 3. Add organization boundaries, roles, export/delete controls, and tested backup/restore. 4. Add managed scheduling and notifications. diff --git a/README.md b/README.md index 0efa51a..2ca868d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AccessForge Document Compliance -[![Version](https://img.shields.io/badge/version-1.34.0-blue)](https://github.com/ICJIA/file-accessibility-audit/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) ![Tests](https://img.shields.io/badge/tests-1410%20passing-brightgreen) ![Node](https://img.shields.io/badge/node-%E2%89%A522-339933?logo=node.js&logoColor=white) ![Nuxt 4](https://img.shields.io/badge/Nuxt-4-00DC82?logo=nuxt&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white) ![Audits: WCAG 2.2 AA](https://img.shields.io/badge/audits-WCAG%202.2%20AA-blueviolet) +[![Version](https://img.shields.io/badge/version-1.34.0-blue)](https://github.com/mycomind4-arch/AccessForge/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) ![Tests](https://img.shields.io/badge/tests-1410%20passing-brightgreen) ![Node](https://img.shields.io/badge/node-%E2%89%A522-339933?logo=node.js&logoColor=white) ![Nuxt 4](https://img.shields.io/badge/Nuxt-4-00DC82?logo=nuxt&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white) ![Audits: WCAG 2.2 AA](https://img.shields.io/badge/audits-WCAG%202.2%20AA-blueviolet) ![AccessForge Document Compliance](apps/web/public/og-image.png) @@ -30,28 +30,28 @@ being developed on top of that foundation. > legal compliance. Human review and assistive-technology testing remain part > of a defensible accessibility program. -A web tool that **audits** PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) accessibility — and **(optionally) auto-remediates** PDFs — against [WCAG 2.2 AA](https://www.w3.org/WAI/WCAG22/quickref/) (a strict superset of [WCAG 2.1 AA](https://www.w3.org/WAI/WCAG21/quickref/), the legal minimum under [IITAA 2.1 §E205.4](https://doit.illinois.gov/initiatives/accessibility.html) and [ADA Title II](https://www.ada.gov/resources/title-ii-rule/)), and [Illinois IITAA 2.1](https://doit.illinois.gov/initiatives/accessibility.html) — all on infrastructure you control, with no AI and no per-document fees. To revert to WCAG 2.1 labels: set `WCAG_VERSION=2.1` and redeploy (API reverts on restart; web UI on rebuild). +A self-hosted web tool that **audits** PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) accessibility — and **optionally auto-remediates** PDFs — using WCAG-aligned checks, detailed findings, and explicit manual-review indicators. Set `WCAG_VERSION=2.1` to use WCAG 2.1 labels; otherwise WCAG 2.2 is the default. Automated results are diagnostic and are not a legal compliance or certification claim. ## What it does | | Feature | Detail | |---|---------|--------| | **9** | WCAG categories audited | Each document (PDF, Word, PowerPoint, or Excel) scored across the WCAG-aligned categories that apply to its format (up to 9) — a weighted 0–100 score (A–F grade) plus a separate, binary pass/fail **WCAG 2.2 conformance verdict**. | -| **F → A** | Auto-remediation (optional) | Tag untagged PDFs in seconds: qpdf → [OpenDataLoader](https://github.com/opendataloader-project/opendataloader-pdf) → [veraPDF](https://verapdf.org/). Output is rejected if it regresses the score. Manual review still recommended for IITAA compliance. | -| **PDF/UA-1** | Standards aligned | WCAG 2.2 AA (superset of 2.1 AA), ADA Title II (April 2026), Illinois IITAA 2.1, PDF/UA-1 via veraPDF. Full lifecycle audit trail with `fs.stat`-verified deletion events for compliance reporting. | +| **F → A** | Auto-remediation (optional) | Tag untagged PDFs in seconds: qpdf → [OpenDataLoader](https://github.com/opendataloader-project/opendataloader-pdf) → [veraPDF](https://verapdf.org/). Output is rejected if it regresses the score. Manual accessibility review is still required. | +| **PDF/UA-1** | Standards aligned | WCAG 2.2 AA-aligned checks (including the 2.1 criteria), ADA Title II context, and optional PDF/UA-1 signals via veraPDF. Full lifecycle audit trail with `fs.stat`-verified deletion events for compliance reporting. | | **0** | Files retained | Audit: in-memory only, gone in seconds. Remediation: output deleted on first download or 30-minute TTL, then verified absent. | | **$0** | No AI, no third-party APIs | Every step runs on your own server. No data sent to vision models, hosted AI services, or commercial PDF/Office SDKs. | -| **100%** | Open source | Apache 2.0 / MIT / MPL toolchain. No per-document fees, no SDK licensing. Designed for state agencies that need control over their pipeline. | +| **100%** | Open source | Apache 2.0 / MIT / MPL toolchain. No per-document fees, no SDK licensing. Designed for organizations that need control over their pipeline. | | **3** | Files per batch | Upload up to 3 files (PDF, Word, PowerPoint, or Excel) at once; per-tab remediation for PDFs. `POST /api/analyze-url` for programmatic auditing of public documents. | | **4** | Export formats | Text / HTML / Markdown / JSON report exports. 1-year shareable links (no login required to view). | Auto-remediation is **disabled by default** — set `REMEDIATION_ENABLED=true` in your environment to enable. Architectural details in [docs/archive/pdf-remediation-integration-plan.md](docs/archive/pdf-remediation-integration-plan.md); the Phase 1 follow-on (interactive alt-text walkthrough) is specced in [docs/archive/pdf-remediation-alt-text-walkthrough-spec.md](docs/archive/pdf-remediation-alt-text-walkthrough-spec.md). -The intended workflow is: **upload → review findings → either auto-remediate or fix at the source (Word, InDesign, etc.) and re-export → re-upload to verify.** Manual review remains essential for full IITAA compliance regardless of which path is taken — the tool's job is to find issues and reduce the manual remediation surface, not replace human review. +The intended workflow is: **upload → review findings → either auto-remediate or fix at the source (Word, InDesign, etc.) and re-export → re-upload to verify.** Manual review remains essential regardless of which path is taken — the tool's job is to find issues and reduce the manual remediation surface, not replace human review. ## Contents -New here? The live tool is at **[audit.icjia.app](https://audit.icjia.app)**; this README is the technical companion. Jump to: +New here? Start with the local Docker deployment in [PRODUCT.md](PRODUCT.md); this README is the technical companion. Jump to: - **Overview** — [What it does](#what-it-does) · [Scoring rubric](#scoring-rubric) - **Run it** — [Quick Start](#quick-start) · [Authentication](#authentication) · [Configuration](#configuration) · [Deployment](#deployment) @@ -119,26 +119,27 @@ pnpm rebrand # Regenerate static files after changing BRANDING in audit.confi ## Authentication -Authentication is **off by default**. The app can be used without any login, email provider, or credentials. This is controlled by a single toggle in `audit.config.ts`: +Authentication is **off by default**. Enable email-OTP login through environment configuration: -```ts -export const AUTH = { - REQUIRE_LOGIN: false, // ← set to true to enable OTP authentication - // ... -}; +```env +AUTH_REQUIRE_LOGIN=true +ALLOWED_DOMAINS=example.org,partner.example +JWT_SECRET=replace-with-a-high-entropy-secret ``` -### With auth disabled (`REQUIRE_LOGIN: false` — default) +When login is enabled in production, configure a verified SMTP sender and credentials as shown in `apps/api/.env.example.production`. + +### With auth disabled (`AUTH_REQUIRE_LOGIN=false` — default) - Users go straight to the upload page — no login screen - No email provider or SMTP credentials needed - No audit history is recorded (no user identity to associate with analyses) - All security protections (rate limiting, file validation, CORS) remain active -### With auth enabled (`REQUIRE_LOGIN: true`) +### With auth enabled (`AUTH_REQUIRE_LOGIN=true`) - Users must authenticate via a **6-digit one-time password (OTP)** sent to their email -- Only `illinois.gov` email addresses are accepted (configurable via `AUTH.ALLOWED_EMAIL_REGEX`) +- Only domains listed in `ALLOWED_DOMAINS` are accepted; exact domains and their subdomains are allowed - Sessions last 72 hours via JWT in an httpOnly cookie — no passwords stored - All analyses are logged with the authenticated user's email for audit history - **Requires an email provider** — the app needs to send OTP codes (see below) @@ -152,20 +153,14 @@ When authentication is enabled, the app sends one-time passcodes via email. This | Mailgun (default) | [docs/archive/07-mailgun-integration.md](docs/archive/07-mailgun-integration.md) | | SMTP2GO | [docs/archive/06-smtp2go-integration.md](docs/archive/06-smtp2go-integration.md) | -The provider is controlled in `audit.config.ts` → `EMAIL.PROVIDER`. Credentials go in `apps/api/.env`: +The provider and SMTP connection are controlled by environment variables. Credentials go in `apps/api/.env`: ```env SMTP_USER=your-smtp-login SMTP_PASS=your-smtp-password ``` -**To switch providers**, change one line in `audit.config.ts`: - -```ts -PROVIDER: "mailgun"; // ← change to 'smtp2go' to switch -``` - -Host and port are set automatically per provider. +**To switch providers**, set `EMAIL_PROVIDER=mailgun` or `EMAIL_PROVIDER=smtp2go`; override `SMTP_HOST` and `SMTP_PORT` for another compatible relay. **Dev note:** When running locally with auth enabled, OTP codes are printed to the API console — no email credentials needed for development. @@ -1296,7 +1291,7 @@ Security reviews for prior releases were not yet captured in this format. Going ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for a full list of changes by version, or view [releases on GitHub](https://github.com/ICJIA/file-accessibility-audit/releases). +See [CHANGELOG.md](CHANGELOG.md) for a full list of changes by version, or view [releases on GitHub](https://github.com/mycomind4-arch/AccessForge/releases). ## License diff --git a/SECURITY.md b/SECURITY.md index f0ea2bd..13ab703 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,7 @@ Important limitations: - [ ] Set `ANALYZE_URL_ALLOWED_HOSTS` to the customer-approved public hosts; keep private/reserved-address blocking enabled. - [ ] Generate high-entropy values for every enabled secret; do not reuse development values. - [ ] If the privileged API tier is enabled, generate and rotate `API_PRIVILEGED_TOKEN` as a secret and never put it in URLs or logs. -- [ ] If login is enabled in code, configure a strong `JWT_SECRET`, verified SMTP sender, approved domains, and named administrators; test login, logout, revocation, and account removal. +- [ ] If `AUTH_REQUIRE_LOGIN=true`, configure a strong `JWT_SECRET`, verified `SMTP_FROM`, SMTP credentials, `ALLOWED_DOMAINS`, and named administrators; test login, logout, revocation, and account removal. - [ ] Keep `REMEDIATION_ENABLED=false` unless the engagement explicitly requires it and its external runtime is tested. - [ ] Back up the SQLite data volume, encrypt backups, test restore, and document retention/deletion ownership. - [ ] Run `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm typecheck`, `pnpm build`, and `pnpm test` for the exact deployed commit. diff --git a/apps/api/.env.example.local b/apps/api/.env.example.local index eb2c04c..f830ca9 100644 --- a/apps/api/.env.example.local +++ b/apps/api/.env.example.local @@ -1,26 +1,30 @@ NODE_ENV=development PORT=5103 -# Generate with: openssl rand -hex 32 +AUTH_REQUIRE_LOGIN=false +ALLOWED_DOMAINS=example.org JWT_SECRET=dev-secret-do-not-use-in-production # Database DB_PATH=./data/audit.db -# Email credentials (provider is set in audit.config.ts → EMAIL.PROVIDER) -# In dev, OTP codes are logged to console — these are optional locally. -SMTP_USER=postmaster@icjia.cloud -SMTP_PASS=your-mailgun-smtp-password -# SMTP_FROM=admin@icjia.cloud # optional — defaults to EMAIL.DEFAULT_FROM in config -# SMTP_HOST= # optional — defaults to provider host in config -# SMTP_PORT= # optional — defaults to provider port in config +# Optional locally. Without SMTP credentials, OTP codes are logged to the console. +EMAIL_PROVIDER=mailgun +SMTP_HOST=smtp.mailgun.org +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=AccessForge # Limits MAX_FILE_SIZE_MB=15 TMP_DIR=/tmp -# Admin -ADMIN_EMAILS=dev@test.illinois.gov +# Admin — comma-separated email addresses +ADMIN_EMAILS=admin@example.org -# Development: comma-separated extra domains for testing -ALLOWED_DOMAINS=illinois.gov +# URL audits are disabled until explicitly allowlisted. +ANALYZE_URL_ALLOWED_HOSTS=example.org,www.example.org + +# Optional GraphQL endpoint used only by the CLI publist command. +PUBLIST_GRAPHQL_ENDPOINT= diff --git a/apps/api/.env.example.production b/apps/api/.env.example.production index b3ffbe0..8b3a6e6 100644 --- a/apps/api/.env.example.production +++ b/apps/api/.env.example.production @@ -1,22 +1,32 @@ NODE_ENV=production PORT=5103 +# Authentication is off by default. When enabled, all settings below are required. +AUTH_REQUIRE_LOGIN=false +ALLOWED_DOMAINS=example.org # Generate with: openssl rand -hex 32 JWT_SECRET=REPLACE-WITH-REAL-SECRET # Database DB_PATH=./data/audit.db -# Email credentials (provider is set in audit.config.ts → EMAIL.PROVIDER) -SMTP_USER=postmaster@icjia.cloud +# SMTP (required only when authentication is enabled) +EMAIL_PROVIDER=mailgun +SMTP_HOST=smtp.mailgun.org +SMTP_PORT=587 +SMTP_USER=your-smtp-login SMTP_PASS=REPLACE-WITH-REAL-PASSWORD +SMTP_FROM=AccessForge # Limits MAX_FILE_SIZE_MB=15 TMP_DIR=/tmp -# Admin — comma-separated list of admin email addresses -ADMIN_EMAILS=REPLACE-WITH-REAL-ADMIN-EMAILS +# Admin — comma-separated email addresses +ADMIN_EMAILS=admin@example.org -# Production: leave empty — only illinois.gov regex is enforced -ALLOWED_DOMAINS= +# URL audits are disabled until explicitly allowlisted. +ANALYZE_URL_ALLOWED_HOSTS=example.org,www.example.org + +# Optional GraphQL endpoint used only by the CLI publist command. +PUBLIST_GRAPHQL_ENDPOINT= diff --git a/apps/api/src/__tests__/analyze-url.test.ts b/apps/api/src/__tests__/analyze-url.test.ts index a907d34..f8767d6 100644 --- a/apps/api/src/__tests__/analyze-url.test.ts +++ b/apps/api/src/__tests__/analyze-url.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { isAllowedUrl } from "../services/urlPolicy.js"; // --------------------------------------------------------------------------- @@ -14,6 +14,17 @@ import { isAllowedUrl } from "../services/urlPolicy.js"; // bulk-from-inventory.test.ts). // --------------------------------------------------------------------------- +const ORIGINAL_ALLOWED_HOSTS = process.env.ANALYZE_URL_ALLOWED_HOSTS; + +beforeEach(() => { + process.env.ANALYZE_URL_ALLOWED_HOSTS = "example.org, partner.test"; +}); + +afterEach(() => { + if (ORIGINAL_ALLOWED_HOSTS === undefined) delete process.env.ANALYZE_URL_ALLOWED_HOSTS; + else process.env.ANALYZE_URL_ALLOWED_HOSTS = ORIGINAL_ALLOWED_HOSTS; +}); + // --------------------------------------------------------------------------- // Helpers: minimal mock req/res // --------------------------------------------------------------------------- @@ -40,7 +51,7 @@ function makeRes() { describe("isAllowedUrl: scheme validation", () => { it("rejects ftp:// scheme", () => { - const r = isAllowedUrl("ftp://icjia.illinois.gov/a.pdf"); + const r = isAllowedUrl("ftp://docs.example.org/a.pdf"); expect(r.ok).toBe(false); expect(r.reason).toMatch(/http/); }); @@ -51,12 +62,12 @@ describe("isAllowedUrl: scheme validation", () => { }); it("accepts https://", () => { - const r = isAllowedUrl("https://icjia.illinois.gov/a.pdf"); + const r = isAllowedUrl("https://docs.example.org/a.pdf"); expect(r.ok).toBe(true); }); it("accepts http://", () => { - const r = isAllowedUrl("http://icjia.illinois.gov/a.pdf"); + const r = isAllowedUrl("http://docs.example.org/a.pdf"); expect(r.ok).toBe(true); }); }); @@ -100,27 +111,27 @@ describe("isAllowedUrl: SSRF prevention — private/local addresses", () => { }); describe("isAllowedUrl: allowlist enforcement", () => { - it("rejects a public but non-ICJIA host", () => { + it("rejects a public but non-configured host", () => { const r = isAllowedUrl("https://example.com/a.pdf"); expect(r.ok).toBe(false); expect(r.reason).toMatch(/allowlist/); }); - it("accepts icjia.illinois.gov (exact match)", () => { - expect(isAllowedUrl("https://icjia.illinois.gov/a.pdf").ok).toBe(true); + it("accepts docs.example.org (exact match)", () => { + expect(isAllowedUrl("https://docs.example.org/a.pdf").ok).toBe(true); }); - it("accepts subdomain of icjia-api.cloud", () => { - expect(isAllowedUrl("https://dvfr.icjia-api.cloud/a.pdf").ok).toBe(true); + it("accepts subdomain of example.org", () => { + expect(isAllowedUrl("https://files.example.org/a.pdf").ok).toBe(true); }); - it("accepts a deep subdomain of icjia-api.cloud", () => { - expect(isAllowedUrl("https://docs.dvfr.icjia-api.cloud/a.pdf").ok).toBe(true); + it("accepts a deep subdomain of example.org", () => { + expect(isAllowedUrl("https://deep.files.example.org/a.pdf").ok).toBe(true); }); it("rejects a hostname that only contains an allowed host as a substring (no subdomain)", () => { - // 'icjia-api.cloud.evil.com' must not match 'icjia-api.cloud' - const r = isAllowedUrl("https://icjia-api.cloud.evil.com/a.pdf"); + // 'example.org.evil.com' must not match 'example.org' + const r = isAllowedUrl("https://example.org.evil.com/a.pdf"); expect(r.ok).toBe(false); }); @@ -135,27 +146,27 @@ describe("isAllowedUrl: allowlist enforcement", () => { } }); - it("accepts any *.illinois.gov subdomain (covers state agencies)", () => { - expect(isAllowedUrl("https://idph.illinois.gov/file.pdf").ok).toBe(true); - expect(isAllowedUrl("https://www.illinois.gov/file.pdf").ok).toBe(true); - expect(isAllowedUrl("https://illinois.gov/file.pdf").ok).toBe(true); + it("accepts any *.example.org subdomain (covers state agencies)", () => { + expect(isAllowedUrl("https://agency.example.org/file.pdf").ok).toBe(true); + expect(isAllowedUrl("https://www.example.org/file.pdf").ok).toBe(true); + expect(isAllowedUrl("https://example.org/file.pdf").ok).toBe(true); }); - it("accepts any *.icjia.cloud and *.icjia.app subdomain", () => { - expect(isAllowedUrl("https://admin.icjia.cloud/file.pdf").ok).toBe(true); - expect(isAllowedUrl("https://audit.icjia.app/file.pdf").ok).toBe(true); + it("accepts any *.example.org and *.example.org subdomain", () => { + expect(isAllowedUrl("https://admin.example.org/file.pdf").ok).toBe(true); + expect(isAllowedUrl("https://audit.example.org/file.pdf").ok).toBe(true); }); - it("accepts ilheals.com and its subdomains", () => { - expect(isAllowedUrl("https://ilheals.com/file.pdf").ok).toBe(true); - expect(isAllowedUrl("https://www.ilheals.com/file.pdf").ok).toBe(true); + it("accepts partner.test and its subdomains", () => { + expect(isAllowedUrl("https://partner.test/file.pdf").ok).toBe(true); + expect(isAllowedUrl("https://www.partner.test/file.pdf").ok).toBe(true); }); - it("rejects look-alike domains that only contain illinois.gov as a substring", () => { - // 'illinois.gov.evil.com' must NOT match 'illinois.gov' - expect(isAllowedUrl("https://illinois.gov.evil.com/file.pdf").ok).toBe(false); - // 'fakeillinois.gov' must NOT match 'illinois.gov' (no subdomain dot) - expect(isAllowedUrl("https://fakeillinois.gov/file.pdf").ok).toBe(false); + it("rejects look-alike domains that only contain example.org as a substring", () => { + // 'example.org.evil.com' must NOT match 'example.org' + expect(isAllowedUrl("https://example.org.evil.com/file.pdf").ok).toBe(false); + // 'fakeexample.org' must NOT match 'example.org' (no subdomain dot) + expect(isAllowedUrl("https://fakeexample.org/file.pdf").ok).toBe(false); }); }); @@ -254,14 +265,14 @@ describe("analyze-url route: fetch error handling", () => { describe("analyze-url route: filename derivation", () => { it("extracts the last path segment as filename", () => { - const parsed = new URL("https://icjia.illinois.gov/docs/2024/annual-report.pdf"); + const parsed = new URL("https://docs.example.org/docs/2024/annual-report.pdf"); const raw = parsed.pathname.split("/").pop() ?? "remote.pdf"; const filename = raw.slice(0, 200) || "remote.pdf"; expect(filename).toBe("annual-report.pdf"); }); it("falls back to remote.pdf for a root-path URL", () => { - const parsed = new URL("https://icjia.illinois.gov/"); + const parsed = new URL("https://docs.example.org/"); const raw = parsed.pathname.split("/").pop() ?? "remote.pdf"; const filename = raw.slice(0, 200) || "remote.pdf"; expect(filename).toBe("remote.pdf"); diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts index 09dcc7b..7376d58 100644 --- a/apps/api/src/__tests__/auth.test.ts +++ b/apps/api/src/__tests__/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; import type { Response, NextFunction } from "express"; @@ -478,64 +478,41 @@ describe("adminMiddleware", () => { }); // --------------------------------------------------------------------------- -// isAllowedEmail — tested via the auth route module +// Configured email-domain allowlist // --------------------------------------------------------------------------- -describe("isAllowedEmail (via auth route)", () => { - // isAllowedEmail is not exported, but we can test the regex from the config - // and the domain logic by importing AUTH directly. +describe("buildAllowedEmailRegex", () => { + it("accepts configured domains and their subdomains case-insensitively", async () => { + const { buildAllowedEmailRegex } = await import("#config"); + const allowed = buildAllowedEmailRegex("example.org, district.gov"); - // We import the regex from config and test it directly since the function - // is private to the auth route module. - let ALLOWED_EMAIL_REGEX: RegExp; - - beforeEach(async () => { - const config = await import("#config"); - ALLOWED_EMAIL_REGEX = config.AUTH.ALLOWED_EMAIL_REGEX; - }); - - it("accepts user@illinois.gov", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@illinois.gov")).toBe(true); - }); - - it("accepts user@icjia.illinois.gov (subdomain)", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@icjia.illinois.gov")).toBe(true); - }); - - it("accepts user@dhs.illinois.gov (another subdomain)", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@dhs.illinois.gov")).toBe(true); + expect(allowed.test("user@example.org")).toBe(true); + expect(allowed.test("user@sub.example.org")).toBe(true); + expect(allowed.test("User@DEPT.DISTRICT.GOV")).toBe(true); }); - it("accepts user@deep.sub.illinois.gov (deep subdomain)", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@deep.sub.illinois.gov")).toBe(true); - }); + it("rejects lookalikes, unrelated domains, and malformed addresses", async () => { + const { buildAllowedEmailRegex } = await import("#config"); + const allowed = buildAllowedEmailRegex("example.org"); - it("is case insensitive", () => { - expect(ALLOWED_EMAIL_REGEX.test("User@ILLINOIS.GOV")).toBe(true); + expect(allowed.test("user@notexample.org")).toBe(false); + expect(allowed.test("user@example.org.evil.com")).toBe(false); + expect(allowed.test("user@gmail.com")).toBe(false); + expect(allowed.test("@example.org")).toBe(false); + expect(allowed.test("userexample.org")).toBe(false); }); - it("rejects user@gmail.com", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@gmail.com")).toBe(false); - }); - - it("rejects user@notillinois.gov", () => { - expect(ALLOWED_EMAIL_REGEX.test("user@notillinois.gov")).toBe(false); - }); - - it("rejects user@illinois.gov.evil.com", () => { - // The regex anchors with $, so this should not match - expect(ALLOWED_EMAIL_REGEX.test("user@illinois.gov.evil.com")).toBe(false); - }); - - it("rejects empty string", () => { - expect(ALLOWED_EMAIL_REGEX.test("")).toBe(false); - }); + it("ignores invalid domain entries and fails closed when none remain", async () => { + const { buildAllowedEmailRegex } = await import("#config"); - it("rejects email without @ sign", () => { - expect(ALLOWED_EMAIL_REGEX.test("userillinois.gov")).toBe(false); + expect(buildAllowedEmailRegex("").test("user@example.org")).toBe(false); + expect(buildAllowedEmailRegex("https://example.org,*,localhost").test("user@example.org")).toBe( + false, + ); }); - it("rejects @illinois.gov without local part", () => { - expect(ALLOWED_EMAIL_REGEX.test("@illinois.gov")).toBe(false); + it("accepts an optional leading @ in operator configuration", async () => { + const { buildAllowedEmailRegex } = await import("#config"); + expect(buildAllowedEmailRegex("@example.org").test("user@example.org")).toBe(true); }); }); diff --git a/apps/api/src/__tests__/mailer.test.ts b/apps/api/src/__tests__/mailer.test.ts index e496232..b709225 100644 --- a/apps/api/src/__tests__/mailer.test.ts +++ b/apps/api/src/__tests__/mailer.test.ts @@ -18,7 +18,7 @@ vi.mock("nodemailer", () => ({ // Store original env values const originalEnv: Record = {}; -const envKeys = ["NODE_ENV", "SMTP_USER", "SMTP_PASS", "SMTP_HOST", "SMTP_PORT"]; +const envKeys = ["NODE_ENV", "SMTP_USER", "SMTP_PASS", "SMTP_FROM", "SMTP_HOST", "SMTP_PORT"]; beforeEach(() => { for (const key of envKeys) { @@ -76,6 +76,22 @@ describe("validateMailConfig", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it("calls process.exit(1) in production without SMTP_FROM", async () => { + process.env.NODE_ENV = "production"; + process.env.SMTP_USER = "user@example.com"; + process.env.SMTP_PASS = "secret123"; + delete process.env.SMTP_FROM; + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { validateMailConfig } = await import("../mailer.js"); + validateMailConfig(); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls.flat().join(" ")).toContain("SMTP_FROM"); + }); + it("warns but continues in development without SMTP credentials", async () => { process.env.NODE_ENV = "development"; delete process.env.SMTP_USER; @@ -91,7 +107,7 @@ describe("validateMailConfig", () => { expect(warnSpy).toHaveBeenCalled(); const allWarnings = warnSpy.mock.calls.map((c) => c.join(" ")).join(" "); - expect(allWarnings).toContain("SMTP credentials not set"); + expect(allWarnings).toContain("SMTP credentials or sender not set"); expect(allWarnings).toContain("console"); }); @@ -114,6 +130,7 @@ describe("validateMailConfig", () => { process.env.NODE_ENV = "development"; process.env.SMTP_USER = "user@example.com"; process.env.SMTP_PASS = "secret123"; + process.env.SMTP_FROM = "AccessForge "; const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -134,6 +151,7 @@ describe("validateMailConfig", () => { process.env.NODE_ENV = "production"; process.env.SMTP_USER = "user@example.com"; process.env.SMTP_PASS = "secret123"; + process.env.SMTP_FROM = "AccessForge "; const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/apps/api/src/__tests__/urlPolicy.test.ts b/apps/api/src/__tests__/urlPolicy.test.ts index ffc3c93..ba7f4c4 100644 --- a/apps/api/src/__tests__/urlPolicy.test.ts +++ b/apps/api/src/__tests__/urlPolicy.test.ts @@ -13,6 +13,17 @@ import { ANALYSIS } from "#config"; // These tests exercise the REAL policy module — the previous route tests // re-implemented isAllowedUrl locally and validated a copy that could drift. +const ORIGINAL_ALLOWED_HOSTS = process.env.ANALYZE_URL_ALLOWED_HOSTS; + +beforeEach(() => { + process.env.ANALYZE_URL_ALLOWED_HOSTS = "example.org, service.test"; +}); + +afterEach(() => { + if (ORIGINAL_ALLOWED_HOSTS === undefined) delete process.env.ANALYZE_URL_ALLOWED_HOSTS; + else process.env.ANALYZE_URL_ALLOWED_HOSTS = ORIGINAL_ALLOWED_HOSTS; +}); + describe("urlPolicy constants", () => { it("caps URL fetches at the direct-upload size", () => { expect(MAX_PDF_BYTES).toBe(ANALYSIS.MAX_FILE_SIZE_MB * 1024 * 1024); @@ -29,7 +40,7 @@ describe("isAllowedUrl", () => { }); it("rejects non-http(s) schemes", () => { - expect(isAllowedUrl("ftp://icjia.illinois.gov/x.pdf").ok).toBe(false); + expect(isAllowedUrl("ftp://docs.example.org/x.pdf").ok).toBe(false); expect(isAllowedUrl("file:///etc/passwd").ok).toBe(false); }); @@ -53,17 +64,23 @@ describe("isAllowedUrl", () => { } }); - it("allows allowlisted hosts and their subdomains", () => { - expect(isAllowedUrl("https://illinois.gov/a.pdf").ok).toBe(true); - expect(isAllowedUrl("https://icjia.illinois.gov/a.pdf").ok).toBe(true); - expect(isAllowedUrl("https://dvfr.icjia-api.cloud/a.pdf").ok).toBe(true); - expect(isAllowedUrl("https://audit.icjia.app/a.pdf").ok).toBe(true); + it("allows configured hosts and their subdomains", () => { + expect(isAllowedUrl("https://example.org/a.pdf").ok).toBe(true); + expect(isAllowedUrl("https://docs.example.org/a.pdf").ok).toBe(true); + expect(isAllowedUrl("https://service.test/a.pdf").ok).toBe(true); + expect(isAllowedUrl("https://files.service.test/a.pdf").ok).toBe(true); }); it("rejects lookalike suffixes (no substring matching)", () => { - // evil-illinois.gov must NOT match the 'illinois.gov' entry - expect(isAllowedUrl("https://evil-illinois.gov/a.pdf").ok).toBe(false); - expect(isAllowedUrl("https://notillinois.gov/a.pdf").ok).toBe(false); + expect(isAllowedUrl("https://evil-example.org/a.pdf").ok).toBe(false); + expect(isAllowedUrl("https://notexample.org/a.pdf").ok).toBe(false); + }); + + it("fails closed when no hosts are configured", () => { + delete process.env.ANALYZE_URL_ALLOWED_HOSTS; + const result = isAllowedUrl("https://example.org/a.pdf"); + expect(result.ok).toBe(false); + expect(result.reason).toContain("no URL hosts are configured"); }); it("rejects hosts not on the allowlist", () => { @@ -98,7 +115,7 @@ describe("validateUrlForFetch", () => { ); }); it("passes for an allowlisted host", () => { - expect(() => validateUrlForFetch(new URL("https://illinois.gov/a.pdf"))).not.toThrow(); + expect(() => validateUrlForFetch(new URL("https://example.org/a.pdf"))).not.toThrow(); }); }); diff --git a/apps/api/src/mailer.ts b/apps/api/src/mailer.ts index 860c517..2e37435 100644 --- a/apps/api/src/mailer.ts +++ b/apps/api/src/mailer.ts @@ -1,5 +1,5 @@ import nodemailer from "nodemailer"; -import { EMAIL, AUTH } from "#config"; +import { EMAIL, AUTH, BRANDING } from "#config"; // Resolve SMTP settings: config sets the provider, .env supplies credentials. // SMTP_USER and SMTP_PASS come from .env (secrets never in config). @@ -22,24 +22,28 @@ const transporter = nodemailer.createTransport({ /** * Validates that email is configured. Call before starting the server. - * In production, SMTP_USER and SMTP_PASS are required. + * In production, SMTP_USER, SMTP_PASS, and SMTP_FROM are required. * In development, they're optional (OTPs are logged to console). */ export function validateMailConfig(): void { const isProduction = process.env.NODE_ENV === "production"; - if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { + const missing = ["SMTP_USER", "SMTP_PASS", "SMTP_FROM"].filter( + (key) => !process.env[key]?.trim(), + ); + + if (missing.length > 0) { if (isProduction) { console.error("\n✖ Email provider is not configured."); console.error(` Provider: ${EMAIL.PROVIDER} (${host}:${port})`); - console.error(" Missing: SMTP_USER and/or SMTP_PASS in .env"); + console.error(` Missing: ${missing.join(", ")} in .env`); console.error( " See: docs/archive/07-mailgun-integration.md or docs/archive/06-smtp2go-integration.md\n", ); process.exit(1); } else { - console.warn(`[WARN] SMTP credentials not set — OTP codes will only be logged to console.`); - console.warn(`[WARN] To send real emails, add SMTP_USER and SMTP_PASS to apps/api/.env\n`); + console.warn(`[WARN] SMTP credentials or sender not set — OTP codes will only be logged to console.`); + console.warn(`[WARN] To send real emails, configure SMTP_USER, SMTP_PASS, and SMTP_FROM in apps/api/.env\n`); } } else { console.log(`[API] Email provider: ${EMAIL.PROVIDER} (${host}:${port})`); @@ -61,7 +65,7 @@ export async function sendOTP(to: string, otp: string): Promise { await transporter.sendMail({ from: process.env.SMTP_FROM || EMAIL.DEFAULT_FROM, to, - subject: "File Accessibility Audit — Your Login Code", + subject: `${BRANDING.APP_SHORT_NAME} — Your Login Code`, text: `Your one-time login code is: ${otp}\n\nThis code expires in ${AUTH.OTP_EXPIRY_MINUTES} minutes.\n\nIf you did not request this code, you can safely ignore this email.`, html: `

Your one-time login code is:

diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index db63337..8b5b744 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -14,14 +14,7 @@ const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-do-not-use-in-productio const isProduction = process.env.NODE_ENV === "production"; function isAllowedEmail(email: string): boolean { - if (AUTH.ALLOWED_EMAIL_REGEX.test(email)) return true; - // In development, allow extra domains from env - if (!isProduction && process.env.ALLOWED_DOMAINS) { - const extraDomains = process.env.ALLOWED_DOMAINS.split(",").map((d) => d.trim()); - const emailDomain = email.split("@")[1]?.toLowerCase(); - return extraDomains.some((d) => emailDomain === d || emailDomain?.endsWith(`.${d}`)); - } - return false; + return AUTH.ALLOWED_EMAIL_REGEX.test(email); } function logEvent( @@ -57,7 +50,7 @@ router.post("/request", authRequestLimiter, async (req: Request, res: Response) const normalizedEmail = email.trim().toLowerCase(); if (!isAllowedEmail(normalizedEmail)) { - res.status(400).json({ error: "Only @illinois.gov email addresses are allowed" }); + res.status(400).json({ error: "This email domain is not authorized" }); return; } diff --git a/apps/api/src/services/urlPolicy.ts b/apps/api/src/services/urlPolicy.ts index c94538a..4640b64 100644 --- a/apps/api/src/services/urlPolicy.ts +++ b/apps/api/src/services/urlPolicy.ts @@ -22,41 +22,16 @@ export const FETCH_TIMEOUT_MS = 30_000; // URL allowlist // --------------------------------------------------------------------------- // Keep this conservative — a permissive allowlist turns this endpoint into an -// SSRF vector. Only ICJIA-owned domains are in the default set; operators can -// extend via the ANALYZE_URL_ALLOWED_HOSTS env var (comma-separated hostnames). - -// Each entry matches the host exactly OR any subdomain of it (the -// matcher below uses `host === ah || host.endsWith('.' + ah)`). So -// a bare 'illinois.gov' entry covers illinois.gov itself plus every -// state subdomain (`icjia.illinois.gov`, `idph.illinois.gov`, etc.). -// Operators can extend at runtime via the ANALYZE_URL_ALLOWED_HOSTS -// env var (comma-separated hostnames). -const DEFAULT_ALLOWED_HOSTS = [ - // Illinois state government — covers every *.illinois.gov agency - // hosting PDFs (huge fleet surface). - "illinois.gov", - // ICJIA owned/operated domains - "icjia.cloud", - "icjia.app", - "icjia-api.cloud", - // Partner / program domains - "ilheals.com", - // Specific subdomains kept for documentation; the bare-domain - // entries above already cover them. Listed so operators reading - // the source can see what's known-good without grepping logs. - "icjia.illinois.gov", - "dvfr.icjia-api.cloud", - "i2i.icjia-api.cloud", - "vpp.icjia-api.cloud", - "infonet.icjia-api.cloud", -]; +// SSRF proxy. A fresh AccessForge deployment has no allowed remote hosts. +// Operators must opt in through ANALYZE_URL_ALLOWED_HOSTS (comma-separated +// base hostnames). Each entry matches the exact host and its subdomains. function getAllowedHosts(): Set { const fromEnv = (process.env.ANALYZE_URL_ALLOWED_HOSTS ?? "") .split(",") - .map((s) => s.trim()) - .filter(Boolean); - return new Set([...DEFAULT_ALLOWED_HOSTS, ...fromEnv]); + .map((host) => host.trim().toLowerCase().replace(/\.$/, "")) + .filter((host) => /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(host)); + return new Set(fromEnv); } export function isAllowedUrl(rawUrl: string): { ok: boolean; reason?: string; parsed?: URL } { @@ -90,6 +65,13 @@ export function isAllowedUrl(rawUrl: string): { ok: boolean; reason?: string; pa } const allowed = getAllowedHosts(); + if (allowed.size === 0) { + return { + ok: false, + reason: "no URL hosts are configured; set ANALYZE_URL_ALLOWED_HOSTS", + parsed, + }; + } // Allow exact match OR subdomain match against each allowlisted host let matched = false; for (const ah of allowed) { diff --git a/apps/cli/package.json b/apps/cli/package.json index c5d5ea2..d7ff5b4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@icjia/a11y-audit", + "name": "@accessforge/a11y-audit", "version": "1.34.0", "private": true, "type": "module", diff --git a/apps/cli/src/__tests__/graphql.test.ts b/apps/cli/src/__tests__/graphql.test.ts index 9f0b2a9..a25e3cd 100644 --- a/apps/cli/src/__tests__/graphql.test.ts +++ b/apps/cli/src/__tests__/graphql.test.ts @@ -79,6 +79,10 @@ describe("hasSupportedExtension", () => { }); describe("fetchPublications: extension filtering", () => { + it("fails clearly when the publication endpoint is not configured", async () => { + await expect(fetchPublications("")).rejects.toThrow("PUBLIST_GRAPHQL_ENDPOINT is required"); + }); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -103,7 +107,7 @@ describe("fetchPublications: extension filtering", () => { ]; stubFetchOnce(pubs); - const result = await fetchPublications(); + const result = await fetchPublications("https://api.example.test/graphql"); expect(result.map((p) => p.fileURL)).toEqual(["https://x/a.pdf", "https://x/b.pdf"]); }); @@ -119,7 +123,7 @@ describe("fetchPublications: extension filtering", () => { ]; stubFetchOnce(pubs); - const result = await fetchPublications(); + const result = await fetchPublications("https://api.example.test/graphql"); expect(result.map((p) => p.id)).toEqual(["1", "2", "3", "4"]); }); @@ -131,7 +135,7 @@ describe("fetchPublications: extension filtering", () => { ]; stubFetchOnce(pubs); - const result = await fetchPublications(); + const result = await fetchPublications("https://api.example.test/graphql"); expect(result).toHaveLength(1); }); diff --git a/apps/cli/src/lib/graphql.ts b/apps/cli/src/lib/graphql.ts index 8ad7682..7f98d70 100644 --- a/apps/cli/src/lib/graphql.ts +++ b/apps/cli/src/lib/graphql.ts @@ -4,9 +4,8 @@ import { PUBLIST } from "#config"; // Document types the accessibility scoring engine can audit end-to-end via // analyzeDocument's content-sniffing dispatcher (@file-audit/analyzer's // analyzer.ts) — the same four-extension allowlist apps/cli/src/commands/ -// audit.ts applies to direct file arguments. The ICJIA publications API has -// historically returned only PDFs in practice, but a publication's fileURL -// may point to any of these, so publist audits whichever it actually is +// audit.ts applies to direct file arguments. A configured publications API may +// return any of these, so publist audits whichever format it actually is // instead of hard-filtering to `.pdf`. export const SUPPORTED_EXTENSIONS = [".pdf", ".docx", ".pptx", ".xlsx"] as const; @@ -32,7 +31,15 @@ export interface Publication { tags: string[] | null; } -export async function fetchPublications(): Promise { +export async function fetchPublications( + endpoint = PUBLIST.GRAPHQL_ENDPOINT, +): Promise { + if (!endpoint) { + throw new Error( + "PUBLIST_GRAPHQL_ENDPOINT is required when running the publist command.", + ); + } + const all: Publication[] = []; let offset = 0; @@ -50,7 +57,7 @@ export async function fetchPublications(): Promise { } }`; - const resp = await fetch(PUBLIST.GRAPHQL_ENDPOINT, { + const resp = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), diff --git a/apps/web/.env.example.production b/apps/web/.env.example.production index f9e2ae3..c26147d 100644 --- a/apps/web/.env.example.production +++ b/apps/web/.env.example.production @@ -1,2 +1,2 @@ -NUXT_PUBLIC_APP_NAME=File Accessibility Audit +NUXT_PUBLIC_APP_NAME=AccessForge NODE_ENV=production diff --git a/apps/web/app/components/dataRetention/Section12Standards.vue b/apps/web/app/components/dataRetention/Section12Standards.vue index f696d6e..c2e9827 100644 --- a/apps/web/app/components/dataRetention/Section12Standards.vue +++ b/apps/web/app/components/dataRetention/Section12Standards.vue @@ -1,52 +1,40 @@ diff --git a/apps/web/app/pages/technical-details.vue b/apps/web/app/pages/technical-details.vue index 9407762..34e7d3a 100644 --- a/apps/web/app/pages/technical-details.vue +++ b/apps/web/app/pages/technical-details.vue @@ -16,7 +16,7 @@ useHead({ { name: "description", content: - "How the ICJIA File Accessibility Audit tool analyzes PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) documents and remediates PDFs — pipeline diagrams, open-source toolchain, and why PDF remediation is fundamentally limited.", + "How AccessForge analyzes PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) documents and remediates PDFs — pipeline diagrams, open-source toolchain, and why automated remediation is fundamentally limited.", }, ], link: [ @@ -108,7 +108,7 @@ function goBack(): void {

- Both happen on a single DigitalOcean server controlled by ICJIA. Nothing leaves the server. + Both happen inside the deployment you control. Ordinary audit files are processed locally and are not sent to hosted AI services. No AI service is contacted at any point.

@@ -288,7 +288,7 @@ function goBack(): void {

WCAG 2.2 alignment

This tool reports against WCAG {{ wcag.version }} Level AA, a strict - superset of the WCAG 2.1 AA that IITAA 2.1 (§E205.4) and ADA Title II require. WCAG 2.2 + superset of WCAG 2.1 AA. WCAG 2.2 adds nine success criteria (six at Level A/AA) and removes one (4.1.1 Parsing, obsolete). The automated checks are unchanged — every machine-checkable criterion carried forward from 2.1. The new 2.2 criteria are interactive/manual; we never report them as automated @@ -302,8 +302,7 @@ function goBack(): void { to="/wcag-2-2" class="text-[var(--link)] hover:text-[var(--link-hover)] underline" >how WCAG 2.2 differs from 2.1. IITAA 2.1 does not yet reference WCAG 2.2, so 2.2 conformance is - optional/forward-looking; WCAG 2.1 AA remains the legal minimum. + >. Operators must determine the applicable legal standard and complete the manual checks that automated analysis cannot cover.

@@ -570,7 +569,7 @@ function goBack(): void {

Automated accessibility scoring and remediation for PDF, Word, PowerPoint, and Excel files, built by AccessForge. -This web application analyzes PDF (.pdf), Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) documents for accessibility and produces a detailed audit report: a weighted 0-100 score (A-F grade) plus a separate, binary WCAG 2.2 Level AA conformance verdict, scored against WCAG 2.2 Level AA, ADA Title II, and Illinois IITAA 2.1 requirements. +This web application analyzes PDF (.pdf), Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) documents for accessibility and produces a detailed audit report: a weighted 0-100 score (A-F grade) plus a separate, binary WCAG 2.2 Level AA conformance verdict, using WCAG-aligned checks with ADA Title II context and explicit manual-review indicators. ## What it does @@ -38,13 +38,12 @@ Approximate category weights by format: - WCAG 2.2 Level AA (a strict superset of WCAG 2.1 AA; every machine-checkable criterion carries forward unchanged from 2.1 into 2.2) - ADA Title II (effective April 2026) -- Illinois IITAA 2.1 (§E205.4) - Section 508 - PDF/UA (ISO 14289-1) — PDF only, not applicable to Word/PowerPoint/Excel ## API -The application exposes a REST API. Protected endpoints accept a session cookie or a Bearer personal access token (`fap_` prefix); the public deployment currently runs with auth disabled, so no credential is required. +The application exposes a REST API. Protected endpoints accept a session cookie or a Bearer personal access token (`fap_` prefix). Authentication is deployment-configurable and disabled by default. - POST /api/analyze - Upload a PDF, Word, PowerPoint, or Excel file for analysis (multipart/form-data, field: "file", max 15 MB) - POST /api/analyze-url - Audit a document (any of the four formats) by URL instead of upload (JSON body: `{ "url": "..." }`); returns the same result shape as /api/analyze @@ -56,7 +55,7 @@ The application exposes a REST API. Protected endpoints accept a session cookie ## Links -- Website: http://localhost:5102 +- Website: deployment-specific (`http://localhost:5102` by default) - Source: https://github.com/mycomind4-arch/AccessForge - Organization: https://github.com/mycomind4-arch/AccessForge diff --git a/audit.config.ts b/audit.config.ts index d7d02b8..2f9538a 100644 --- a/audit.config.ts +++ b/audit.config.ts @@ -53,13 +53,12 @@ export const BRANDING = { GITHUB_URL: "https://github.com/mycomind4-arch/AccessForge", /** - * URL for the Illinois Information Technology Accessibility Act - * (IITAA) reference. Shown in the post-remediation compliance - * disclaimer so users can read the standard our outputs aim to - * support. Update if the State of Illinois reorganizes the canonical - * page. Empty string hides the link. + * Optional jurisdiction-specific accessibility-standard URL. + * Empty by default so a fresh deployment never implies that a + * particular state standard governs the customer. Set IITAA_URL only + * for an Illinois deployment that needs the IITAA reference. */ - IITAA_URL: "https://doit.illinois.gov/initiatives/accessibility/iitaa.html", + IITAA_URL: process.env.IITAA_URL || "", /** * URL for the veraPDF homepage. Shown in the post-remediation @@ -81,13 +80,11 @@ export const BRANDING = { // --------------------------------------------------------------------------- // The operative reference standard the whole app displays and links to. // -// We audit against WCAG 2.2 Level AA — a SUPERSET of the WCAG 2.1 AA that -// IITAA 2.1 (§E205.4) and the ADA Title II rule actually require. Auditing to -// 2.2 is stricter than the Illinois legal minimum; 2.2 is optional/forward- -// looking under IITAA today. The automated checks are unchanged — every -// machine-checkable criterion carried forward from 2.1 into 2.2. The new 2.2 -// criteria are interactive/manual and are surfaced as "not assessed", never as -// automated failures. +// We audit against WCAG 2.2 Level AA. The automated checks cover the +// machine-checkable criteria carried forward from WCAG 2.1; new 2.2 criteria +// that require interaction or human judgment are surfaced as "not assessed", +// never as automated failures. Jurisdiction-specific legal applicability must +// be reviewed separately for each customer. // // REVERT PATH: set WCAG_VERSION=2.1 in the environment (PM2 env block or // /etc/environment), then: @@ -251,9 +248,9 @@ export const DEPLOY = { // PUBLIST (CLI publication-list audit) // --------------------------------------------------------------------------- // Settings for `a11y-audit publist` (apps/cli/src/commands/publist.ts and -// apps/cli/src/lib/graphql.ts), which fetches ICJIA's publication list over -// GraphQL, audits each file, and copies the generated HTML report into the -// web app's public/ directory so it's servable at /publist. +// apps/cli/src/lib/graphql.ts), which fetches a configured publication list +// over GraphQL, audits each file, and copies the generated HTML report into +// the web app's public/ directory so it is servable at /publist. // // SAFE TO CHANGE: Yes for all three values — none are scoring- or security- // sensitive. Update GRAPHQL_ENDPOINT if the agency API moves; update @@ -262,8 +259,8 @@ export const DEPLOY = { // --------------------------------------------------------------------------- export const PUBLIST = { - /** ICJIA publications GraphQL API endpoint, queried by fetchPublications(). */ - GRAPHQL_ENDPOINT: "https://agency.icjia-api.cloud/graphql", + /** Publication GraphQL endpoint. Empty means remote publist fetch is disabled. */ + GRAPHQL_ENDPOINT: process.env.PUBLIST_GRAPHQL_ENDPOINT || "", /** * Publications fetched per GraphQL page. fetchPublications() pages through @@ -301,7 +298,9 @@ export const EMAIL = { * * SAFE TO CHANGE: Yes — set to 'mailgun' or 'smtp2go'. */ - PROVIDER: "mailgun" as "mailgun" | "smtp2go", + PROVIDER: (process.env.EMAIL_PROVIDER === "smtp2go" ? "smtp2go" : "mailgun") as + | "mailgun" + | "smtp2go", /** * Default sender address for OTP emails. @@ -309,7 +308,7 @@ export const EMAIL = { * SAFE TO CHANGE: Yes — must match a verified sender on the active provider. * Can be overridden in .env with SMTP_FROM. */ - DEFAULT_FROM: "admin@icjia.cloud", + DEFAULT_FROM: process.env.SMTP_FROM || "accessforge@example.invalid", /** Mailgun SMTP connection details (no secrets). */ mailgun: { @@ -778,6 +777,26 @@ export const ANALYSIS = { // Note: JWT_SECRET is in .env (per-environment secret), not here. // --------------------------------------------------------------------------- +/** + * Build an anchored, case-insensitive allowlist regex from a comma-separated + * list of base domains. Exact domains and their subdomains are accepted. + * Invalid entries are ignored; an empty/invalid list returns a regex that + * matches nothing so enabling login without ALLOWED_DOMAINS fails closed. + */ +export function buildAllowedEmailRegex(rawDomains: string | undefined): RegExp { + const domains = (rawDomains ?? "") + .split(",") + .map((domain) => domain.trim().toLowerCase().replace(/^@/, "")) + .filter((domain) => + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain), + ); + + if (domains.length === 0) return /^(?!)$/; + + const escaped = domains.map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp(`^[^@\\s]+@(?:[a-z0-9-]+\\.)*(?:${escaped.join("|")})$`, "i"); +} + export const AUTH = { /** * Master switch for OTP-based authentication. @@ -796,7 +815,7 @@ export const AUTH = { * SAFE TO CHANGE: Yes — flip to true once email delivery is configured * and you want to gate access behind OTP authentication. */ - REQUIRE_LOGIN: false, + REQUIRE_LOGIN: process.env.AUTH_REQUIRE_LOGIN === "true", /** * How long a JWT session lasts, in hours. @@ -837,17 +856,11 @@ export const AUTH = { OTP_LENGTH: 6, /** - * Regex pattern for allowed email domains. - * Only users with email addresses matching this pattern can authenticate. - * - * SAFE TO CHANGE: Yes — e.g., to add additional state domains. The regex - * must be case-insensitive and anchor both sides. The current pattern - * allows any subdomain of illinois.gov (e.g., icjia.illinois.gov, - * dhs.illinois.gov, etc.). - * - * ALSO UPDATE: the .env ALLOWED_DOMAINS variable for development overrides. + * Allowed login domains from ALLOWED_DOMAINS (comma-separated). + * Exact domains and subdomains are accepted. Empty/invalid configuration + * matches no address, so authentication remains fail-closed. */ - ALLOWED_EMAIL_REGEX: /^[^@]+@([a-z0-9-]+\.)*illinois\.gov$/i, + ALLOWED_EMAIL_REGEX: buildAllowedEmailRegex(process.env.ALLOWED_DOMAINS), } as const; // --------------------------------------------------------------------------- diff --git a/compose.yaml b/compose.yaml index 858f8a2..06d6c32 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,7 +9,19 @@ services: PRODUCTION_URL: ${PRODUCTION_URL:-http://localhost:5102} DB_PATH: /app/apps/api/data/audit.db REMEDIATION_ENABLED: "false" + AUTH_REQUIRE_LOGIN: ${AUTH_REQUIRE_LOGIN:-false} + ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-} + JWT_SECRET: ${JWT_SECRET:-} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} + EMAIL_PROVIDER: ${EMAIL_PROVIDER:-mailgun} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} ANALYZE_URL_ALLOWED_HOSTS: ${ANALYZE_URL_ALLOWED_HOSTS:-} + PUBLIST_GRAPHQL_ENDPOINT: ${PUBLIST_GRAPHQL_ENDPOINT:-} + IITAA_URL: ${IITAA_URL:-} API_PRIVILEGED_TOKEN: ${API_PRIVILEGED_TOKEN:-} ports: - "${ACCESSFORGE_WEB_PORT:-5102}:5102" @@ -19,4 +31,3 @@ services: volumes: accessforge-data: - diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 955d742..fc8f8ec 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -24,7 +24,7 @@ const remediationEnv = { module.exports = { apps: [ { - name: "file-audit-api", + name: "accessforge-api", cwd: "./apps/api", script: "pnpm", args: "start", @@ -35,6 +35,19 @@ module.exports = { // Privileged rate-limit + allowlist-bypass token (see audit.config.ts). // Forwarded from the shell / Forge / /etc/environment; empty = off. API_PRIVILEGED_TOKEN: process.env.API_PRIVILEGED_TOKEN || "", + AUTH_REQUIRE_LOGIN: process.env.AUTH_REQUIRE_LOGIN || "false", + ALLOWED_DOMAINS: process.env.ALLOWED_DOMAINS || "", + JWT_SECRET: process.env.JWT_SECRET || "", + ADMIN_EMAILS: process.env.ADMIN_EMAILS || "", + EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || "mailgun", + SMTP_HOST: process.env.SMTP_HOST || "", + SMTP_PORT: process.env.SMTP_PORT || "", + SMTP_USER: process.env.SMTP_USER || "", + SMTP_PASS: process.env.SMTP_PASS || "", + SMTP_FROM: process.env.SMTP_FROM || "", + ANALYZE_URL_ALLOWED_HOSTS: process.env.ANALYZE_URL_ALLOWED_HOSTS || "", + PUBLIST_GRAPHQL_ENDPOINT: process.env.PUBLIST_GRAPHQL_ENDPOINT || "", + IITAA_URL: process.env.IITAA_URL || "", ...remediationEnv, }, watch: false, @@ -43,7 +56,7 @@ module.exports = { exp_backoff_restart_delay: 100, }, { - name: "file-audit-web", + name: "accessforge-web", cwd: "./apps/web", script: "pnpm", args: "start", diff --git a/packages/shared/src/scoring.ts b/packages/shared/src/scoring.ts index 2d23986..791d78a 100644 --- a/packages/shared/src/scoring.ts +++ b/packages/shared/src/scoring.ts @@ -8,13 +8,13 @@ export const SCORING_PROFILES = { strict: { - label: "Strict semantic score (WCAG + IITAA §E205.4)", + label: "Strict semantic score (WCAG-aligned)", // Origin tag surfaced in JSON exports so downstream consumers can tell // which profile produced a given score. - origin: "wcag.iitaa.strict", - originLabel: "WCAG + IITAA §E205.4", + origin: "wcag.strict", + originLabel: "WCAG-aligned strict", description: - "WCAG-based scoring methodology. Anchored to WCAG 2.1 Level AA and Illinois IITAA §E205.4 for non-web documents. Nine categories, no PDF/UA category. Requires explicit heading and table semantics rather than visual or bookmark-only cues.", + "WCAG-aligned scoring methodology for non-web documents. Nine categories, no PDF/UA category. Requires explicit heading and table semantics rather than visual or bookmark-only cues.", weights: { /** Is the PDF text-based (not scanned) and tagged? Highest weight because * a scanned PDF is fundamentally inaccessible — nothing else matters. */ @@ -78,7 +78,7 @@ export const SCORING_PROFILES = { origin: "wcag.pdfua.practical", originLabel: "WCAG + PDF/UA signals", description: - "WCAG-based scoring methodology with different category weights than Strict and an added PDF/UA Compliance Signals category (MarkInfo, tab order, PDF/UA identifiers, list/table legality). Applies partial-credit floors on heading and table structure. PDF/UA is referenced in IITAA §504.2.2 for authoring-tool export capability, while §E205.4 frames final-document accessibility through WCAG 2.1. Diagnostic only — not a WCAG, ADA, ITTAA, PDF/UA, or Matterhorn conformance claim.", + "WCAG-based scoring methodology with different category weights than Strict and an added PDF/UA Compliance Signals category (MarkInfo, tab order, PDF/UA identifiers, list/table legality). Applies partial-credit floors on heading and table structure. PDF/UA signals supplement the WCAG-aligned checks and help prioritize manual review. Diagnostic only — not a WCAG, ADA, PDF/UA, or Matterhorn conformance claim.", weights: { text_extractability: 0.175, title_language: 0.13, @@ -158,8 +158,8 @@ export const SEVERITY_THRESHOLDS = [ // the auditable "what standard does each category implement" reference: it // is surfaced in the methodology UI and underpins the conformance gate. // -// IITAA 2.1 and the 2024 ADA Title II rule both adopt WCAG 2.1 Level AA. The -// criteria below are all carried forward UNCHANGED into WCAG 2.2 (their numbers +// The criteria below are carried forward unchanged from WCAG 2.1 into WCAG 2.2 +// (their numbers // and slugs are identical), so this map is correct under both versions; the new // 2.2 criteria (see WCAG_22_NEW_AA) are manual/interactive and not mapped here. // diff --git a/scripts/test.ts b/scripts/test.ts index 82c2be6..2ad1a2f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -76,9 +76,9 @@ async function main() { runSuite("Web", "web"), // Package name, not a bare "cli" — pnpm --filter matches on the // package.json "name" field, and apps/cli is published as - // @icjia/a11y-audit. A bare "cli" filter matches zero projects and + // @accessforge/a11y-audit. A bare "cli" filter matches zero projects and // (silently) exits 0, which is how this suite went unrun before. - runSuite("CLI", "@icjia/a11y-audit"), + runSuite("CLI", "@accessforge/a11y-audit"), ]); const totalPassed = results.reduce((s, r) => s + (r.passed ?? 0), 0);