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
-[](https://github.com/ICJIA/file-accessibility-audit/releases) [](LICENSE)     
+[](https://github.com/mycomind4-arch/AccessForge/releases) [](LICENSE)     

@@ -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);