From ef674b430349005cd004bdc47cc4c87464dee5f1 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:54:37 -0700 Subject: [PATCH 01/31] Make AccessForge authentication and service defaults portable --- audit.config.ts | 854 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 823 insertions(+), 31 deletions(-) diff --git a/audit.config.ts b/audit.config.ts index d7d02b8..d9cd4a6 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,805 @@ 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, "\\// Note: JWT_SECRET is in .env (per-environment secret), not here. +// --------------------------------------------------------------------------- + +export const AUTH = {")); + return new RegExp(`^[^@\\s]+@(?:[a-z0-9-]+\\.)*(?:${escaped.join("|")})/** + * audit.config.ts — Single source of truth for ALL configurable constants. + * + * ============================================================================ + * EVERY magic number, threshold, weight, limit, and display constant in this + * project lives here. The API imports this directly. The frontend references it + * via shared types. The design documents (docs/archive/00-master-design.md) describe + * the "why" — this file defines the "what". + * + * RULES: + * 1. If you add a new constant anywhere in the codebase, put it here first. + * 2. Never hardcode a configurable value in a service, route, or component. + * 3. Secrets (JWT_SECRET, SMTP_PASS) stay in .env — this file is committed. + * 4. After changing a value, run `pnpm --filter api test:scoring` to verify + * scoring still produces expected results against test fixtures. + * ============================================================================ + */ + +// --------------------------------------------------------------------------- +// BRANDING +// --------------------------------------------------------------------------- +// All organization-specific branding lives here. Override these values to +// white-label the tool for a different organization. These defaults brand the +// commercial distribution as AccessForge while retaining the upstream license +// and attribution in LICENSE and README.md. +// +// SAFE TO CHANGE: Yes — all values are purely cosmetic or used in URLs. +// After changing, also update these static files manually: +// - apps/web/public/site.webmanifest (name, short_name) +// - apps/web/public/llms.txt (title, organization, URLs) +// - apps/web/public/llms-full.txt (title, organization, URLs) +// - og-image.svg → regenerate og-image.png +// - apps/cli/package.json (package name, if forking) +// --------------------------------------------------------------------------- + +export const BRANDING = { + /** Application name displayed in headers, page titles, exports, and emails. */ + APP_NAME: "AccessForge Document Compliance", + + /** Short app name (for PWA manifest, browser tabs when space is limited). */ + APP_SHORT_NAME: "AccessForge", + + /** Organization name shown in Schema.org, meta tags, and export footers. */ + ORG_NAME: "AccessForge", + + /** Organization website URL (used in Schema.org identity and JSON-LD author). */ + ORG_URL: "https://github.com/mycomind4-arch/AccessForge", + + /** FAQs / documentation URL shown in the navbar. Set to '' to hide the link. */ + FAQS_URL: "", + + /** GitHub repository URL shown in the footer. Set to '' to hide the link. */ + GITHUB_URL: "https://github.com/mycomind4-arch/AccessForge", + + /** + * 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: process.env.IITAA_URL || "", + + /** + * URL for the veraPDF homepage. Shown in the post-remediation + * compliance disclaimer so users can learn what veraPDF is and why + * we use it (open-source PDF/UA-1 / PDF/UA-2 validator backed by + * the PDF Association and Dual Lab). Empty string hides the link. + */ + VERAPDF_URL: "https://verapdf.org/", + + /** Default color mode for the UI. Users can toggle between light and dark via the nav. + * Set to 'dark' for a dark-first experience, or 'light' if your agency's branding + * requires a light default. Users can always switch modes via the toggle in the nav bar. + * SAFE TO CHANGE: 'light' | 'dark' */ + DEFAULT_COLOR_MODE: "dark" as "light" | "dark", +} as const; + +// --------------------------------------------------------------------------- +// WCAG STANDARD VERSION +// --------------------------------------------------------------------------- +// The operative reference standard the whole app displays and links to. +// +// 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: +// - API: restart only (tsx re-reads this file at startup — no rebuild). The +// conformance verdict (labels, links, and the 2.2 "not assessed" additions) +// reverts immediately. +// - Web: rebuild + restart. Nuxt bakes runtimeConfig.public at `nuxt build` +// time (same as REMEDIATION.ENABLED), so the front end picks up 2.1 only +// after `pnpm build` and a restart — not on a bare env change. +// A normal redeploy (which rebuilds the web app) does both at once. +// +// SAFE TO CHANGE: VERSION via env only ("2.1" | "2.2"). Keep URLs accurate — +// a wrong citation is a credibility problem. +// --------------------------------------------------------------------------- + +export const WCAG = { + /** Operative version. Defaults to "2.2"; only "2.1" reverts. */ + VERSION: (process.env.WCAG_VERSION === "2.1" ? "2.1" : "2.2") as "2.1" | "2.2", + LEVEL: "AA" as const, + /** "Understanding" page base URL, version-keyed. Carried-forward criteria + * keep identical slugs across 2.1 and 2.2. */ + UNDERSTANDING_BASE: { + "2.1": "https://www.w3.org/WAI/WCAG21/Understanding/", + "2.2": "https://www.w3.org/WAI/WCAG22/Understanding/", + }, + /** Quick-reference base, version-keyed. */ + QUICKREF: { + "2.1": "https://www.w3.org/WAI/WCAG21/quickref/", + "2.2": "https://www.w3.org/WAI/WCAG22/quickref/", + }, +} as const; + +// --------------------------------------------------------------------------- +// WCAG 2.2 NEW A/AA SUCCESS CRITERIA +// --------------------------------------------------------------------------- +// The six new Level A/AA success criteria introduced in WCAG 2.2 (the three +// AAA additions are described in the /wcag-2-2 page copy but not used by the +// conformance gate). `pdfFormRelevant` marks the ones that can apply to an +// interactive PDF FORM; these are the ones the gate surfaces as "not assessed" +// when a document has form fields (balanced-strict). +// +// SAFE TO CHANGE: Criteria data is locked to the published WCAG 2.2 spec — only +// update if W3C errata change a criterion number, name, level, or slug. Do not +// remove an entry to silence a false positive (the gate already lists these as +// "not assessed", never as failures). Add a future "2.3" set as a new constant +// rather than mutating this one. +// --------------------------------------------------------------------------- +export const WCAG_22_NEW_AA = [ + { + sc: "2.4.11", + name: "Focus Not Obscured (Minimum)", + level: "AA", + slug: "focus-not-obscured-minimum", + pdfFormRelevant: false, + }, + { + sc: "2.5.7", + name: "Dragging Movements", + level: "AA", + slug: "dragging-movements", + pdfFormRelevant: false, + }, + { + sc: "2.5.8", + name: "Target Size (Minimum)", + level: "AA", + slug: "target-size-minimum", + pdfFormRelevant: true, + }, + { + sc: "3.2.6", + name: "Consistent Help", + level: "A", + slug: "consistent-help", + pdfFormRelevant: false, + }, + { + sc: "3.3.7", + name: "Redundant Entry", + level: "A", + slug: "redundant-entry", + pdfFormRelevant: true, + }, + { + sc: "3.3.8", + name: "Accessible Authentication (Minimum)", + level: "AA", + slug: "accessible-authentication-minimum", + pdfFormRelevant: true, + }, +] as const; + +// --------------------------------------------------------------------------- +// LANDING-PAGE ANNOUNCEMENTS +// --------------------------------------------------------------------------- +// A reusable slot for "what's new" on the landing page. To announce a future +// improvement, PREPEND a new entry (index 0 is rendered). Dismissal is +// permanent per `id` (stored client-side); bump the `id` to re-show. +// --------------------------------------------------------------------------- + +export const ANNOUNCEMENTS = [ + { + id: "pptx-xlsx-support-2026-07", + badge: "New", + text: "Now supporting Microsoft PowerPoint (.pptx) and Excel (.xlsx) files — upload a presentation or workbook for the same WCAG 2.2 AA accessibility audit as PDFs and Word documents, with findings and fix guidance tailored to each app.", + linkText: "", + linkTo: "", + /** Shown under the text so visitors can see the tool is actively maintained. */ + date: "July 2, 2026", + /** Only shown while the app is on this WCAG version (null = always). */ + requiresWcagVersion: null as "2.1" | "2.2" | null, + }, + { + id: "docx-support-2026-07", + badge: "New", + text: "Now supporting Microsoft Word (.docx) files — upload a Word document for the same WCAG 2.2 AA accessibility audit as PDFs, with findings and fix guidance tailored to Word.", + linkText: "", + linkTo: "", + /** Shown under the text so visitors can see the tool is actively maintained. */ + date: "July 1, 2026", + /** Only shown while the app is on this WCAG version (null = always). */ + requiresWcagVersion: null as "2.1" | "2.2" | null, + }, +] as const; + +// --------------------------------------------------------------------------- +// DEPLOYMENT +// --------------------------------------------------------------------------- + +export const DEPLOY = { + /** + * The canonical production URL for this application. + * + * Used in: + * - Shared report URLs returned by POST /api/reports + * - OTP email footer (optional "sent from" link) + * - CORS origin validation (production mode) + * - nginx server_name directive + * + * SAFE TO CHANGE: Yes — update when migrating to a new domain. + * ALSO UPDATE: nginx config, DNS A record, Let's Encrypt cert. + */ + PRODUCTION_URL: process.env.PRODUCTION_URL || "http://localhost:5102", + + /** + * Development frontend URL (Nuxt dev server). + * Used for CORS origin in development mode. + * + * SAFE TO CHANGE: Yes — if you change the Nuxt dev port, update this. + */ + DEV_FRONTEND_URL: "http://localhost:5102", + + /** API server port (development and production) */ + API_PORT: 5103, + + /** Frontend server port (Nuxt dev / production) */ + WEB_PORT: 5102, +} as const; + +// --------------------------------------------------------------------------- +// 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 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 +// WEB_PUBLIC_DIR if apps/cli or apps/web ever change location relative to +// each other. +// --------------------------------------------------------------------------- + +export const PUBLIST = { + /** 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 + * the full result set, stopping once a page returns fewer than this many + * rows. + */ + PAGE_SIZE: 500, + + /** + * Path to apps/web/public, relative to apps/cli/ (where publist's output + * CSV/HTML files are written). Used to copy the generated publist.html + * report so it's servable at /publist. Non-fatal if the path doesn't + * resolve (e.g. a checkout without apps/web present). + */ + WEB_PUBLIC_DIR: "../web/public", +} as const; + +// --------------------------------------------------------------------------- +// EMAIL PROVIDER +// --------------------------------------------------------------------------- +// Controls which SMTP relay is used for OTP delivery. Credentials (user, +// pass) stay in .env — only non-secret connection details live here. +// +// To switch providers: change PROVIDER below. Both sets of SMTP settings +// are defined here; the mailer picks the active one automatically. +// Credentials for whichever provider you choose must be in .env as +// SMTP_USER and SMTP_PASS. +// +// SAFE TO CHANGE: Yes — swap PROVIDER any time. No code changes needed. +// --------------------------------------------------------------------------- + +export const EMAIL = { + /** + * Active email provider. Determines which SMTP settings are used. + * + * SAFE TO CHANGE: Yes — set to 'mailgun' or 'smtp2go'. + */ + PROVIDER: (process.env.EMAIL_PROVIDER === "smtp2go" ? "smtp2go" : "mailgun") as + | "mailgun" + | "smtp2go", + + /** + * Default sender address for OTP emails. + * + * SAFE TO CHANGE: Yes — must match a verified sender on the active provider. + * Can be overridden in .env with SMTP_FROM. + */ + DEFAULT_FROM: process.env.SMTP_FROM || "accessforge@example.invalid", + + /** Mailgun SMTP connection details (no secrets). */ + mailgun: { + host: "smtp.mailgun.org", + port: 587, + }, + + /** SMTP2GO SMTP connection details (no secrets). */ + smtp2go: { + host: "mail.smtp2go.com", + port: 2525, + }, +} as const; + +// --------------------------------------------------------------------------- +// SCORING WEIGHTS +// --------------------------------------------------------------------------- +// These weights control how much each accessibility category contributes to +// the overall score. They MUST sum to exactly 1.0. +// +// The weights reflect WCAG 2.1 priority: text extractability is the most +// fundamental requirement (a scanned PDF is completely inaccessible), followed +// by structural elements (title, headings, alt text) that affect the majority +// of assistive technology users. +// +// SAFE TO CHANGE: Yes — but with care. Changing weights changes every +// document's score. After changing, re-run `pnpm --filter api test:scoring` +// and update the .expected.json fixtures if the new weights are intentional. +// +// DO NOT CHANGE the keys — they are used as category IDs throughout the +// codebase and in stored audit log data. Renaming a key is a breaking change. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// SCORING PROFILES / GRADE / SEVERITY / WCAG MAP — moved to packages/shared +// --------------------------------------------------------------------------- +// These are pure, browser-safe data consumed by the web UI as well as the +// API scorer, so they live in @file-audit/shared (packages/shared/src/ +// scoring.ts). Re-exported here so every existing `#config` import keeps +// working unchanged. Edit them THERE. +// --------------------------------------------------------------------------- +export { + SCORING_PROFILES, + SCORING_WEIGHTS, + GRADE_THRESHOLDS, + SEVERITY_THRESHOLDS, + WCAG_CATEGORY_MAP, +} from "@file-audit/shared"; + +// --------------------------------------------------------------------------- +// DOCX (WORD) ANALYSIS +// --------------------------------------------------------------------------- +// Config for the Microsoft Word (.docx) accessibility checker, which runs +// alongside the PDF pipeline. A .docx is a ZIP of OOXML XML parsed in pure JS +// (jszip + fast-xml-parser, no external binary), so once extracted it reuses +// the PDF pipeline's scoring aggregation, grade/severity thresholds, WCAG map, +// conformance-verdict shape, and the entire report UI. +// --------------------------------------------------------------------------- + +export const DOCX = { + /** + * Feature flag. When false, the API rejects .docx uploads/URLs (cleanly + * falling back to PDF-only) and the frontend drops .docx from the dropzone + * and its copy. Lets you keep the rock-solid PDF path and turn Word auditing + * off with no code change. Default is ENABLED (on). PDF auditing is entirely + * unaffected either way. + * + * Reads from env: set DOCX_ENABLED=false to disable. Both API and web read + * the same value at startup; the web app exposes it via + * runtimeConfig.public.docxEnabled. + * + * SAFE TO CHANGE: Yes — flip via env var (shell, or PM2's ecosystem.config + * env block). Don't hardcode `false` here unless you want it off everywhere. + */ + ENABLED: process.env.DOCX_ENABLED !== "false", + + /** Canonical MIME type for .docx (WordprocessingML). */ + MIME_TYPE: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + + /** + * Max UNCOMPRESSED bytes for any single part read out of the .docx ZIP + * (document.xml, styles.xml, etc.). The 15 MB upload cap only limits the + * COMPRESSED size — a decompression ("zip") bomb can inflate a <1 MB upload + * to multiple GB and OOM the process. The reader checks the ZIP's declared + * uncompressed size AND streams with a hard byte cap (declared size can be + * forged), aborting past this limit. 30 MB covers even very large real + * documents; a part bigger than this is not a legitimate Word file. + * + * SAFE TO CHANGE: Yes — lower for tighter memory, raise only with headroom. + * fast-xml-parser's object tree is ~20× the XML string, so 30 MB → ~660 MB + * heap per analysis; keep MAX_CONCURRENT_ANALYSES × this within the RAM budget. + */ + MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, + + /** + * Max number of paragraphs () analyzed. A document that decompresses + * within MAX_UNCOMPRESSED_BYTES but is millions of tiny elements still costs + * CPU/heap in the extract passes; this bounds it. 100k paragraphs ≈ a + * ~2000-page document — far beyond any real report. Over the cap → rejected. + * + * SAFE TO CHANGE: Yes. + */ + MAX_PARAGRAPHS: 100_000, + + /** + * Wall-clock timeout (ms) for a single DOCX analysis, mirroring the PDF + * pipeline's PDFJS_TIMEOUT_MS. Backstops the async decompression phase; the + * synchronous parse/extract is bounded by the size + paragraph caps above. + * On timeout the route returns 504. + * + * SAFE TO CHANGE: Yes. + */ + ANALYSIS_TIMEOUT_MS: 20_000, + + /** + * DOCX category weights. Word maps onto the same category IDs as PDF, except: + * - reading_order / form_accessibility / bookmarks are N/A for Word, + * - color_contrast is machine-checkable for Word (explicit + theme colors), + * - list_structure is a Word-specific category (real lists vs manual bullets), + * - text_extractability auto-passes (Word is always text-based) so it carries + * only a small weight — it must not hand a structureless doc free points. + * + * Weights need not sum to 1 — the scorer renormalizes across the applicable + * (non-null) categories, exactly as it does for PDF N/A categories. + * + * SAFE TO CHANGE: Yes — same rules as SCORING_PROFILES.strict.weights. Keys + * MUST match category IDs. Run `pnpm --filter api test:scoring` afterwards. + */ + SCORING_WEIGHTS: { + text_extractability: 0.05, + title_language: 0.18, + heading_structure: 0.18, + alt_text: 0.18, + table_markup: 0.12, + color_contrast: 0.12, + list_structure: 0.09, + link_quality: 0.08, + }, +} as const; + +// --------------------------------------------------------------------------- +// PPTX (POWERPOINT) ANALYSIS +// --------------------------------------------------------------------------- +// Config for the PowerPoint (.pptx) accessibility checker (v1.33.0). Same +// posture as DOCX: a ZIP of OOXML parts parsed in pure JS on the shared +// services/ooxml.ts core; reuses the PDF pipeline's scoring aggregation, +// grade/severity thresholds, WCAG map, conformance-verdict shape, and the +// report UI. +// --------------------------------------------------------------------------- + +export const PPTX = { + /** Feature flag — set PPTX_ENABLED=false to reject .pptx and hide it in the + * web UI (runtimeConfig.public.pptxEnabled). SAFE TO CHANGE: via env var. */ + ENABLED: process.env.PPTX_ENABLED !== "false", + + /** Canonical MIME type for .pptx (PresentationML). */ + MIME_TYPE: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + + /** Max UNCOMPRESSED bytes per ZIP part (zip-bomb guard) — same rationale + * and budget math as DOCX.MAX_UNCOMPRESSED_BYTES. SAFE TO CHANGE. */ + MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, + + /** Max slides analyzed; over the cap → rejected (CPU/heap bound, the + * MAX_PARAGRAPHS analogue). 2,000 slides is far beyond any real deck. + * SAFE TO CHANGE. */ + MAX_SLIDES: 2000, + + /** Max total shapes across all slides; over the cap → rejected. + * SAFE TO CHANGE. */ + MAX_SHAPES: 100_000, + + /** Max any-depth count of paragraphs () + text runs () across all + * slides; over the cap → rejected. A single shape can legally hold an + * unbounded txBody, and those text elements — not the shape containers — + * drive the per-run contrast walk and per-paragraph list walk, so + * MAX_SHAPES alone does not bound them. This is the MAX_PARAGRAPHS analogue + * for PowerPoint. SAFE TO CHANGE. */ + MAX_TEXT_ELEMENTS: 200_000, + + /** Wall-clock timeout (ms) per analysis; route maps timeout → 504. + * SAFE TO CHANGE. */ + ANALYSIS_TIMEOUT_MS: 20_000, + + /** + * PPTX category weights. PowerPoint maps onto the shared category IDs, + * except: + * - slide_titles is PowerPoint-specific (every slide needs a title); + * - reading_order is ACTIVE (title-first-in-shape-tree is machine-checkable) + * — it is permanently N/A for Word; + * - heading_structure / bookmarks are omitted (slide titles are the + * PowerPoint outline); form_accessibility is a not-assessed placeholder. + * Weights renormalize across applicable categories, as for PDF/DOCX N/A. + * SAFE TO CHANGE: same rules as DOCX.SCORING_WEIGHTS. + */ + SCORING_WEIGHTS: { + text_extractability: 0.05, + title_language: 0.14, + slide_titles: 0.18, + alt_text: 0.18, + reading_order: 0.1, + table_markup: 0.1, + color_contrast: 0.1, + list_structure: 0.07, + link_quality: 0.08, + }, +} as const; + +// --------------------------------------------------------------------------- +// XLSX (EXCEL) ANALYSIS +// --------------------------------------------------------------------------- + +export const XLSX = { + /** Feature flag — set XLSX_ENABLED=false to reject .xlsx and hide it in the + * web UI (runtimeConfig.public.xlsxEnabled). SAFE TO CHANGE: via env var. */ + ENABLED: process.env.XLSX_ENABLED !== "false", + + /** Canonical MIME type for .xlsx (SpreadsheetML). */ + MIME_TYPE: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + + /** Max UNCOMPRESSED bytes per ZIP part (zip-bomb guard) — same rationale as + * DOCX.MAX_UNCOMPRESSED_BYTES. SAFE TO CHANGE. */ + MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, + + /** Max worksheets analyzed; over the cap → rejected. SAFE TO CHANGE. */ + MAX_SHEETS: 200, + + /** Max total used-range cells (worksheet XML is the volume driver — this is + * the MAX_PARAGRAPHS analogue). Checked against the ACTUAL parsed `` + * cells (any depth, accumulated across sheets) — never the self-reported + * ``, which is attacker-controlled (see countCellsAnyDepth's + * doc comment in xlsxService.ts). Over the cap → rejected. SAFE TO CHANGE. */ + MAX_CELLS: 1_000_000, + + /** Max total drawing objects (pictures/charts) across all sheets; over the + * cap → rejected. Otherwise unbounded — limited only by + * MAX_UNCOMPRESSED_BYTES × MAX_SHEETS. Mirrors PPTX.MAX_SHAPES's DoS + * rationale. SAFE TO CHANGE. */ + MAX_DRAWING_OBJECTS: 100_000, + + /** Max total hyperlinks across all sheets; over the cap → rejected. Same + * unbounded-growth rationale as MAX_DRAWING_OBJECTS. SAFE TO CHANGE. */ + MAX_HYPERLINKS: 100_000, + + /** Max total defined tables across all sheets; over the cap → rejected. + * Worse than plain array growth: each <.../table> rel triggers a table-PART + * read + parse (a fan-out READ amplifier), bounded only by the 30 MB + * rels-part cap (~300k rels/sheet) × MAX_SHEETS. Pre-counted before any + * table part is read (see collectSheetContent). 10k far exceeds any + * legitimate workbook while bounding the fan-out. SAFE TO CHANGE. */ + MAX_TABLES: 10_000, + + /** Max total distinct drawing PARTS (relationships) across all sheets; + * over the cap → rejected. A legitimate sheet has ~1 drawing rel (Excel + * packs every drawing on a sheet into one drawingN.xml). Same fan-out + * READ-amplifier class as MAX_TABLES: each `/drawing` rel triggers a + * drawing-PART read + parse BEFORE MAX_DRAWING_OBJECTS can even be + * checked against that part's content, bounded only by the 30 MB + * rels-part cap (~300k rels/sheet) × MAX_SHEETS. Pre-counted before any + * drawing part is read (see collectSheetContent) — mirrors MAX_TABLES'S + * pre-count-before-read pattern exactly. + * RB3-3 [pre-merge re-audit]: tightened from 10,000 -> 1,000. The review + * benchmarked ~729ms/rel for a large, object-sparse drawing part, so only + * ~28 such rels reach the 20s ANALYSIS_TIMEOUT_MS — the 10,000 cap never + * engaged for that shape; it was a count-only bound, not a cost bound. + * 1,000 still comfortably exceeds any legitimate workbook (~1 rel/sheet + * × MAX_SHEETS=200) while shrinking the window; MAX_AUX_PART_BYTES below + * closes the rest of the gap on the SIZE dimension. SAFE TO CHANGE. */ + MAX_DRAWING_RELS: 1_000, + + /** Cumulative UNCOMPRESSED bytes actually read across every drawing + + * defined-table PART in a workbook (all sheets combined) — tracked in + * collectSheetContent's `counts.auxPartBytes` accumulator, checked right + * after each part is read, over the cap → rejected. + * RB3-3 [pre-merge re-audit]: closes a gap MAX_DRAWING_RELS/MAX_TABLES + * leave open even after being count-tightened: a HANDFUL of near-max-size + * (MAX_UNCOMPRESSED_BYTES, 30 MB), object-/row-sparse parts each pass the + * per-part cap individually and never approach the rel-COUNT cap, yet + * parsing each ~30 MB part still costs real wall-clock time (benchmarked + * ~729ms for one such drawing part) — few enough parts that the count cap + * doesn't engage before the 20s ANALYSIS_TIMEOUT_MS eventually would. This + * budget fails fast on the SIZE dimension instead, independent of count: + * ~1.6x one MAX_UNCOMPRESSED_BYTES part — room for one legitimate + * full-sized part plus incidental small ones, but not a second full-sized + * one. A legitimate workbook's drawing/table XML is KB-scale — nowhere + * close. SAFE TO CHANGE. */ + MAX_AUX_PART_BYTES: 48 * 1024 * 1024, + + /** Wall-clock timeout (ms) per analysis; route maps timeout → 504. + * SAFE TO CHANGE. */ + ANALYSIS_TIMEOUT_MS: 20_000, + + /** + * XLSX category weights. Excel maps onto the shared category IDs, except: + * - sheet_names is Excel-specific (no default "Sheet1" names); + * - title_language scores on the title alone (Excel stores no document + * language — the gate lists 3.1.1 as not assessed); + * - table_markup carries the most weight: data as real table objects with + * header rows is THE Excel accessibility fundamental; + * - heading_structure / reading_order / list_structure / bookmarks are + * omitted; form_accessibility is a not-assessed placeholder. + * SAFE TO CHANGE: same rules as DOCX.SCORING_WEIGHTS. + */ + SCORING_WEIGHTS: { + text_extractability: 0.05, + title_language: 0.12, + sheet_names: 0.18, + table_markup: 0.25, + alt_text: 0.18, + color_contrast: 0.12, + link_quality: 0.1, + }, +} as const; + +// --------------------------------------------------------------------------- +// OOXML (DOCX/PPTX/XLSX) SHARED ZIP-PACKAGE LIMITS +// --------------------------------------------------------------------------- +// Aggregate limits enforced once per package, right after JSZip.loadAsync and +// before any part is read — shared by the docx/pptx/xlsx extractors via +// assertZipWithinLimits() in services/ooxml.ts. The per-format +// MAX_UNCOMPRESSED_BYTES constants above bound any ONE part; they say nothing +// about the SUM across every part a package can legally contain (styles, +// dozens of slides/sheets, media, drawings, tables, rels, theme, core/app +// props). A zip built from many separately-legal-sized parts can still cost +// gigabytes of cumulative decompression across a single analysis, and a zip +// with an enormous number of tiny entries costs real CPU/memory just parsing +// JSZip's central directory, before any part is ever read. These two checks +// close both gaps and apply to every OOXML format uniformly. +// +// SAFE TO CHANGE: Yes for both values — pick values comfortably above any +// real-world Word/PowerPoint/Excel document; see each constant's note. +// --------------------------------------------------------------------------- + +export const OOXML = { + /** + * Maximum number of entries (files + directories) in the ZIP central + * directory. Real documents rarely exceed a few hundred parts even with + * many embedded images; 10,000 leaves generous headroom while bounding a + * "many tiny files" package designed to cost CPU/memory in JSZip's own + * central-directory parse before any content is even read. + * + * SAFE TO CHANGE: Yes. + */ + MAX_ZIP_ENTRIES: 10_000, + + /** + * Maximum SUM of every entry's declared uncompressed size (bytes) across + * the whole package. Checked once, right after JSZip.loadAsync, against + * the ZIP central directory's declared sizes (cheap — no decompression + * happens yet). Each per-format MAX_UNCOMPRESSED_BYTES (30 MB) already + * bounds any single part; this bounds the total across ALL parts, so a + * package built from many separately-legal-sized parts can't add up to + * an unbounded decompression bill. 512 MB is ~17x one full-sized part — + * comfortably above any legitimate Word/PowerPoint/Excel file (whose real + * total is normally single-digit MB to tens of MB even with heavy + * embedded media) while still bounding the aggregate. + * + * Declared sizes are attacker-controlled metadata (same caveat as + * readCapped's fast-reject check in ooxml.ts) — this is a cheap + * fast-fail, not the only guard; readCapped's streaming per-part cap + * remains the authoritative defense against a forged declared size. + * + * SAFE TO CHANGE: Yes. + */ + MAX_TOTAL_UNCOMPRESSED_BYTES: 512 * 1024 * 1024, +} as const; + +// --------------------------------------------------------------------------- +// PDF ANALYSIS LIMITS +// --------------------------------------------------------------------------- +// Operational limits for the PDF analysis pipeline. These protect the server +// from resource exhaustion and define category-specific behavior thresholds. +// +// SAFE TO CHANGE: Yes for all values, but read the notes on each. +// --------------------------------------------------------------------------- + +export const ANALYSIS = { + /** + * Maximum file upload size in megabytes. + * + * Enforced in three places (all must agree): + * 1. multer `limits.fileSize` in uploadMiddleware.ts + * 2. nginx `client_max_body_size` (set to this + 10MB headroom for headers) + * 3. Frontend file picker validation (immediate user feedback) + * + * SAFE TO CHANGE: Yes — but increasing above 50MB on a 4GB droplet risks + * OOM kills during concurrent uploads. If you increase this, also increase + * the nginx `client_max_body_size` in the Forge nginx config. + */ + MAX_FILE_SIZE_MB: 15, + + /** + * QPDF subprocess timeout in milliseconds. + * If QPDF hasn't finished parsing within this window, the process is killed + * and the API returns HTTP 504. + * + * SAFE TO CHANGE: Yes — increase if legitimate complex PDFs are timing out. + * Decrease if you want faster failure on adversarial inputs. 30s is a + * reasonable default; most PDFs finish in under 5s. + */ + QPDF_TIMEOUT_MS: 30_000, + + /** + * Maximum stdout buffer for QPDF JSON output, in bytes. + * QPDF's `--json` output can be very large for PDFs with deep structure + * trees or many objects. If the output exceeds this, execFileSync throws. + * + * SAFE TO CHANGE: Yes — increase if you see "maxBuffer exceeded" errors + * on legitimate PDFs. 50MB handles most documents; very complex government + * reports with thousands of tagged elements may need more. + */ + QPDF_MAX_BUFFER: 50 * 1024 * 1024, + + /** + * Wall-clock cap for the pdfjs extraction pass, in milliseconds. + * Unlike QPDF (a subprocess with its own timeout), pdfjs runs in-process, + * so a pathological PDF — millions of operators, a huge page count — can + * otherwise pin one of the MAX_CONCURRENT_ANALYSES slots indefinitely. On + * timeout the analysis is abandoned (HTTP 504) and the slot is freed so a + * single adversarial upload can't starve the queue. + * + * SAFE TO CHANGE: Yes — raise if legitimate large documents time out; + * lower for faster failure on adversarial inputs. 60s comfortably covers + * real government reports while bounding abuse. + */ + PDFJS_TIMEOUT_MS: 60_000, + + /** + * Maximum number of PDFs being analyzed simultaneously. + * Implemented as a semaphore in pdfAnalyzer.ts. Requests beyond this limit + * wait in a queue (or return 503 if the queue is also full). + * + * SAFE TO CHANGE: Yes — but on a 4GB droplet, 2 is the safe maximum. + * Each analysis can consume 50MB+ in memory (multer buffer + QPDF process). + * Increase only if you upgrade the droplet's RAM. + */ + MAX_CONCURRENT_ANALYSES: 2, + + /** + * Minimum page count to require bookmarks/outlines. + * Documents with fewer pages than this score N/A on the Bookmarks category + * instead of being penalized for missing bookmarks. + * + * SAFE TO CHANGE: Yes — WCAG doesn't specify an exact threshold. 10 is + * conservative. Some organizations use 4 or 5 pages. + */ + BOOKMARKS_PAGE_THRESHOLD: 10, + + /** + * Reading order: fraction of out-of-order MCIDs that triggers a score + * reduction. If more than this fraction of content items are out of + * sequence relative to the page content stream, the reading order score + * drops from 100 to 50. + * + * SAFE TO CHANGE: Yes — increase to be more lenient (e.g., 0.30 = allow + * 30% out-of-order before penalizing), decrease to be stricter. + */ + READING_ORDER_DISORDER_THRESHOLD: 0.2, +} as const; + +// --------------------------------------------------------------------------- +// AUTHENTICATION +// --------------------------------------------------------------------------- +// Controls for the OTP-based auth system. These values are also referenced +// in the auth flow description (doc 00, Section 3) and rate limiting. +// +, "i"); +} + export const AUTH = { /** * Master switch for OTP-based authentication. @@ -796,7 +1594,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 +1635,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; // --------------------------------------------------------------------------- From e5c6a51d0f3105c10f9980911824f3697a8b41c4 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:54:40 -0700 Subject: [PATCH 02/31] Use configured login domains in every environment --- apps/api/src/routes/auth.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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; } From b992f3175ca04141bcaee857271b2c7868bf1faf Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:54:42 -0700 Subject: [PATCH 03/31] Require an explicit URL audit allowlist --- apps/api/src/services/urlPolicy.ts | 44 +++++++++--------------------- 1 file changed, 13 insertions(+), 31 deletions(-) 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) { From bd3e739f8ec561c8b7334d3d7eb14bba8823964f Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:55:33 -0700 Subject: [PATCH 04/31] Repair and apply portable AccessForge configuration literally --- audit.config.ts | 783 +----------------------------------------------- 1 file changed, 2 insertions(+), 781 deletions(-) diff --git a/audit.config.ts b/audit.config.ts index d9cd4a6..2f9538a 100644 --- a/audit.config.ts +++ b/audit.config.ts @@ -793,787 +793,8 @@ export function buildAllowedEmailRegex(rawDomains: string | undefined): RegExp { if (domains.length === 0) return /^(?!)$/; - const escaped = domains.map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, "\\// Note: JWT_SECRET is in .env (per-environment secret), not here. -// --------------------------------------------------------------------------- - -export const AUTH = {")); - return new RegExp(`^[^@\\s]+@(?:[a-z0-9-]+\\.)*(?:${escaped.join("|")})/** - * audit.config.ts — Single source of truth for ALL configurable constants. - * - * ============================================================================ - * EVERY magic number, threshold, weight, limit, and display constant in this - * project lives here. The API imports this directly. The frontend references it - * via shared types. The design documents (docs/archive/00-master-design.md) describe - * the "why" — this file defines the "what". - * - * RULES: - * 1. If you add a new constant anywhere in the codebase, put it here first. - * 2. Never hardcode a configurable value in a service, route, or component. - * 3. Secrets (JWT_SECRET, SMTP_PASS) stay in .env — this file is committed. - * 4. After changing a value, run `pnpm --filter api test:scoring` to verify - * scoring still produces expected results against test fixtures. - * ============================================================================ - */ - -// --------------------------------------------------------------------------- -// BRANDING -// --------------------------------------------------------------------------- -// All organization-specific branding lives here. Override these values to -// white-label the tool for a different organization. These defaults brand the -// commercial distribution as AccessForge while retaining the upstream license -// and attribution in LICENSE and README.md. -// -// SAFE TO CHANGE: Yes — all values are purely cosmetic or used in URLs. -// After changing, also update these static files manually: -// - apps/web/public/site.webmanifest (name, short_name) -// - apps/web/public/llms.txt (title, organization, URLs) -// - apps/web/public/llms-full.txt (title, organization, URLs) -// - og-image.svg → regenerate og-image.png -// - apps/cli/package.json (package name, if forking) -// --------------------------------------------------------------------------- - -export const BRANDING = { - /** Application name displayed in headers, page titles, exports, and emails. */ - APP_NAME: "AccessForge Document Compliance", - - /** Short app name (for PWA manifest, browser tabs when space is limited). */ - APP_SHORT_NAME: "AccessForge", - - /** Organization name shown in Schema.org, meta tags, and export footers. */ - ORG_NAME: "AccessForge", - - /** Organization website URL (used in Schema.org identity and JSON-LD author). */ - ORG_URL: "https://github.com/mycomind4-arch/AccessForge", - - /** FAQs / documentation URL shown in the navbar. Set to '' to hide the link. */ - FAQS_URL: "", - - /** GitHub repository URL shown in the footer. Set to '' to hide the link. */ - GITHUB_URL: "https://github.com/mycomind4-arch/AccessForge", - - /** - * 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: process.env.IITAA_URL || "", - - /** - * URL for the veraPDF homepage. Shown in the post-remediation - * compliance disclaimer so users can learn what veraPDF is and why - * we use it (open-source PDF/UA-1 / PDF/UA-2 validator backed by - * the PDF Association and Dual Lab). Empty string hides the link. - */ - VERAPDF_URL: "https://verapdf.org/", - - /** Default color mode for the UI. Users can toggle between light and dark via the nav. - * Set to 'dark' for a dark-first experience, or 'light' if your agency's branding - * requires a light default. Users can always switch modes via the toggle in the nav bar. - * SAFE TO CHANGE: 'light' | 'dark' */ - DEFAULT_COLOR_MODE: "dark" as "light" | "dark", -} as const; - -// --------------------------------------------------------------------------- -// WCAG STANDARD VERSION -// --------------------------------------------------------------------------- -// The operative reference standard the whole app displays and links to. -// -// 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: -// - API: restart only (tsx re-reads this file at startup — no rebuild). The -// conformance verdict (labels, links, and the 2.2 "not assessed" additions) -// reverts immediately. -// - Web: rebuild + restart. Nuxt bakes runtimeConfig.public at `nuxt build` -// time (same as REMEDIATION.ENABLED), so the front end picks up 2.1 only -// after `pnpm build` and a restart — not on a bare env change. -// A normal redeploy (which rebuilds the web app) does both at once. -// -// SAFE TO CHANGE: VERSION via env only ("2.1" | "2.2"). Keep URLs accurate — -// a wrong citation is a credibility problem. -// --------------------------------------------------------------------------- - -export const WCAG = { - /** Operative version. Defaults to "2.2"; only "2.1" reverts. */ - VERSION: (process.env.WCAG_VERSION === "2.1" ? "2.1" : "2.2") as "2.1" | "2.2", - LEVEL: "AA" as const, - /** "Understanding" page base URL, version-keyed. Carried-forward criteria - * keep identical slugs across 2.1 and 2.2. */ - UNDERSTANDING_BASE: { - "2.1": "https://www.w3.org/WAI/WCAG21/Understanding/", - "2.2": "https://www.w3.org/WAI/WCAG22/Understanding/", - }, - /** Quick-reference base, version-keyed. */ - QUICKREF: { - "2.1": "https://www.w3.org/WAI/WCAG21/quickref/", - "2.2": "https://www.w3.org/WAI/WCAG22/quickref/", - }, -} as const; - -// --------------------------------------------------------------------------- -// WCAG 2.2 NEW A/AA SUCCESS CRITERIA -// --------------------------------------------------------------------------- -// The six new Level A/AA success criteria introduced in WCAG 2.2 (the three -// AAA additions are described in the /wcag-2-2 page copy but not used by the -// conformance gate). `pdfFormRelevant` marks the ones that can apply to an -// interactive PDF FORM; these are the ones the gate surfaces as "not assessed" -// when a document has form fields (balanced-strict). -// -// SAFE TO CHANGE: Criteria data is locked to the published WCAG 2.2 spec — only -// update if W3C errata change a criterion number, name, level, or slug. Do not -// remove an entry to silence a false positive (the gate already lists these as -// "not assessed", never as failures). Add a future "2.3" set as a new constant -// rather than mutating this one. -// --------------------------------------------------------------------------- -export const WCAG_22_NEW_AA = [ - { - sc: "2.4.11", - name: "Focus Not Obscured (Minimum)", - level: "AA", - slug: "focus-not-obscured-minimum", - pdfFormRelevant: false, - }, - { - sc: "2.5.7", - name: "Dragging Movements", - level: "AA", - slug: "dragging-movements", - pdfFormRelevant: false, - }, - { - sc: "2.5.8", - name: "Target Size (Minimum)", - level: "AA", - slug: "target-size-minimum", - pdfFormRelevant: true, - }, - { - sc: "3.2.6", - name: "Consistent Help", - level: "A", - slug: "consistent-help", - pdfFormRelevant: false, - }, - { - sc: "3.3.7", - name: "Redundant Entry", - level: "A", - slug: "redundant-entry", - pdfFormRelevant: true, - }, - { - sc: "3.3.8", - name: "Accessible Authentication (Minimum)", - level: "AA", - slug: "accessible-authentication-minimum", - pdfFormRelevant: true, - }, -] as const; - -// --------------------------------------------------------------------------- -// LANDING-PAGE ANNOUNCEMENTS -// --------------------------------------------------------------------------- -// A reusable slot for "what's new" on the landing page. To announce a future -// improvement, PREPEND a new entry (index 0 is rendered). Dismissal is -// permanent per `id` (stored client-side); bump the `id` to re-show. -// --------------------------------------------------------------------------- - -export const ANNOUNCEMENTS = [ - { - id: "pptx-xlsx-support-2026-07", - badge: "New", - text: "Now supporting Microsoft PowerPoint (.pptx) and Excel (.xlsx) files — upload a presentation or workbook for the same WCAG 2.2 AA accessibility audit as PDFs and Word documents, with findings and fix guidance tailored to each app.", - linkText: "", - linkTo: "", - /** Shown under the text so visitors can see the tool is actively maintained. */ - date: "July 2, 2026", - /** Only shown while the app is on this WCAG version (null = always). */ - requiresWcagVersion: null as "2.1" | "2.2" | null, - }, - { - id: "docx-support-2026-07", - badge: "New", - text: "Now supporting Microsoft Word (.docx) files — upload a Word document for the same WCAG 2.2 AA accessibility audit as PDFs, with findings and fix guidance tailored to Word.", - linkText: "", - linkTo: "", - /** Shown under the text so visitors can see the tool is actively maintained. */ - date: "July 1, 2026", - /** Only shown while the app is on this WCAG version (null = always). */ - requiresWcagVersion: null as "2.1" | "2.2" | null, - }, -] as const; - -// --------------------------------------------------------------------------- -// DEPLOYMENT -// --------------------------------------------------------------------------- - -export const DEPLOY = { - /** - * The canonical production URL for this application. - * - * Used in: - * - Shared report URLs returned by POST /api/reports - * - OTP email footer (optional "sent from" link) - * - CORS origin validation (production mode) - * - nginx server_name directive - * - * SAFE TO CHANGE: Yes — update when migrating to a new domain. - * ALSO UPDATE: nginx config, DNS A record, Let's Encrypt cert. - */ - PRODUCTION_URL: process.env.PRODUCTION_URL || "http://localhost:5102", - - /** - * Development frontend URL (Nuxt dev server). - * Used for CORS origin in development mode. - * - * SAFE TO CHANGE: Yes — if you change the Nuxt dev port, update this. - */ - DEV_FRONTEND_URL: "http://localhost:5102", - - /** API server port (development and production) */ - API_PORT: 5103, - - /** Frontend server port (Nuxt dev / production) */ - WEB_PORT: 5102, -} as const; - -// --------------------------------------------------------------------------- -// 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 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 -// WEB_PUBLIC_DIR if apps/cli or apps/web ever change location relative to -// each other. -// --------------------------------------------------------------------------- - -export const PUBLIST = { - /** 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 - * the full result set, stopping once a page returns fewer than this many - * rows. - */ - PAGE_SIZE: 500, - - /** - * Path to apps/web/public, relative to apps/cli/ (where publist's output - * CSV/HTML files are written). Used to copy the generated publist.html - * report so it's servable at /publist. Non-fatal if the path doesn't - * resolve (e.g. a checkout without apps/web present). - */ - WEB_PUBLIC_DIR: "../web/public", -} as const; - -// --------------------------------------------------------------------------- -// EMAIL PROVIDER -// --------------------------------------------------------------------------- -// Controls which SMTP relay is used for OTP delivery. Credentials (user, -// pass) stay in .env — only non-secret connection details live here. -// -// To switch providers: change PROVIDER below. Both sets of SMTP settings -// are defined here; the mailer picks the active one automatically. -// Credentials for whichever provider you choose must be in .env as -// SMTP_USER and SMTP_PASS. -// -// SAFE TO CHANGE: Yes — swap PROVIDER any time. No code changes needed. -// --------------------------------------------------------------------------- - -export const EMAIL = { - /** - * Active email provider. Determines which SMTP settings are used. - * - * SAFE TO CHANGE: Yes — set to 'mailgun' or 'smtp2go'. - */ - PROVIDER: (process.env.EMAIL_PROVIDER === "smtp2go" ? "smtp2go" : "mailgun") as - | "mailgun" - | "smtp2go", - - /** - * Default sender address for OTP emails. - * - * SAFE TO CHANGE: Yes — must match a verified sender on the active provider. - * Can be overridden in .env with SMTP_FROM. - */ - DEFAULT_FROM: process.env.SMTP_FROM || "accessforge@example.invalid", - - /** Mailgun SMTP connection details (no secrets). */ - mailgun: { - host: "smtp.mailgun.org", - port: 587, - }, - - /** SMTP2GO SMTP connection details (no secrets). */ - smtp2go: { - host: "mail.smtp2go.com", - port: 2525, - }, -} as const; - -// --------------------------------------------------------------------------- -// SCORING WEIGHTS -// --------------------------------------------------------------------------- -// These weights control how much each accessibility category contributes to -// the overall score. They MUST sum to exactly 1.0. -// -// The weights reflect WCAG 2.1 priority: text extractability is the most -// fundamental requirement (a scanned PDF is completely inaccessible), followed -// by structural elements (title, headings, alt text) that affect the majority -// of assistive technology users. -// -// SAFE TO CHANGE: Yes — but with care. Changing weights changes every -// document's score. After changing, re-run `pnpm --filter api test:scoring` -// and update the .expected.json fixtures if the new weights are intentional. -// -// DO NOT CHANGE the keys — they are used as category IDs throughout the -// codebase and in stored audit log data. Renaming a key is a breaking change. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// SCORING PROFILES / GRADE / SEVERITY / WCAG MAP — moved to packages/shared -// --------------------------------------------------------------------------- -// These are pure, browser-safe data consumed by the web UI as well as the -// API scorer, so they live in @file-audit/shared (packages/shared/src/ -// scoring.ts). Re-exported here so every existing `#config` import keeps -// working unchanged. Edit them THERE. -// --------------------------------------------------------------------------- -export { - SCORING_PROFILES, - SCORING_WEIGHTS, - GRADE_THRESHOLDS, - SEVERITY_THRESHOLDS, - WCAG_CATEGORY_MAP, -} from "@file-audit/shared"; - -// --------------------------------------------------------------------------- -// DOCX (WORD) ANALYSIS -// --------------------------------------------------------------------------- -// Config for the Microsoft Word (.docx) accessibility checker, which runs -// alongside the PDF pipeline. A .docx is a ZIP of OOXML XML parsed in pure JS -// (jszip + fast-xml-parser, no external binary), so once extracted it reuses -// the PDF pipeline's scoring aggregation, grade/severity thresholds, WCAG map, -// conformance-verdict shape, and the entire report UI. -// --------------------------------------------------------------------------- - -export const DOCX = { - /** - * Feature flag. When false, the API rejects .docx uploads/URLs (cleanly - * falling back to PDF-only) and the frontend drops .docx from the dropzone - * and its copy. Lets you keep the rock-solid PDF path and turn Word auditing - * off with no code change. Default is ENABLED (on). PDF auditing is entirely - * unaffected either way. - * - * Reads from env: set DOCX_ENABLED=false to disable. Both API and web read - * the same value at startup; the web app exposes it via - * runtimeConfig.public.docxEnabled. - * - * SAFE TO CHANGE: Yes — flip via env var (shell, or PM2's ecosystem.config - * env block). Don't hardcode `false` here unless you want it off everywhere. - */ - ENABLED: process.env.DOCX_ENABLED !== "false", - - /** Canonical MIME type for .docx (WordprocessingML). */ - MIME_TYPE: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - - /** - * Max UNCOMPRESSED bytes for any single part read out of the .docx ZIP - * (document.xml, styles.xml, etc.). The 15 MB upload cap only limits the - * COMPRESSED size — a decompression ("zip") bomb can inflate a <1 MB upload - * to multiple GB and OOM the process. The reader checks the ZIP's declared - * uncompressed size AND streams with a hard byte cap (declared size can be - * forged), aborting past this limit. 30 MB covers even very large real - * documents; a part bigger than this is not a legitimate Word file. - * - * SAFE TO CHANGE: Yes — lower for tighter memory, raise only with headroom. - * fast-xml-parser's object tree is ~20× the XML string, so 30 MB → ~660 MB - * heap per analysis; keep MAX_CONCURRENT_ANALYSES × this within the RAM budget. - */ - MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, - - /** - * Max number of paragraphs () analyzed. A document that decompresses - * within MAX_UNCOMPRESSED_BYTES but is millions of tiny elements still costs - * CPU/heap in the extract passes; this bounds it. 100k paragraphs ≈ a - * ~2000-page document — far beyond any real report. Over the cap → rejected. - * - * SAFE TO CHANGE: Yes. - */ - MAX_PARAGRAPHS: 100_000, - - /** - * Wall-clock timeout (ms) for a single DOCX analysis, mirroring the PDF - * pipeline's PDFJS_TIMEOUT_MS. Backstops the async decompression phase; the - * synchronous parse/extract is bounded by the size + paragraph caps above. - * On timeout the route returns 504. - * - * SAFE TO CHANGE: Yes. - */ - ANALYSIS_TIMEOUT_MS: 20_000, - - /** - * DOCX category weights. Word maps onto the same category IDs as PDF, except: - * - reading_order / form_accessibility / bookmarks are N/A for Word, - * - color_contrast is machine-checkable for Word (explicit + theme colors), - * - list_structure is a Word-specific category (real lists vs manual bullets), - * - text_extractability auto-passes (Word is always text-based) so it carries - * only a small weight — it must not hand a structureless doc free points. - * - * Weights need not sum to 1 — the scorer renormalizes across the applicable - * (non-null) categories, exactly as it does for PDF N/A categories. - * - * SAFE TO CHANGE: Yes — same rules as SCORING_PROFILES.strict.weights. Keys - * MUST match category IDs. Run `pnpm --filter api test:scoring` afterwards. - */ - SCORING_WEIGHTS: { - text_extractability: 0.05, - title_language: 0.18, - heading_structure: 0.18, - alt_text: 0.18, - table_markup: 0.12, - color_contrast: 0.12, - list_structure: 0.09, - link_quality: 0.08, - }, -} as const; - -// --------------------------------------------------------------------------- -// PPTX (POWERPOINT) ANALYSIS -// --------------------------------------------------------------------------- -// Config for the PowerPoint (.pptx) accessibility checker (v1.33.0). Same -// posture as DOCX: a ZIP of OOXML parts parsed in pure JS on the shared -// services/ooxml.ts core; reuses the PDF pipeline's scoring aggregation, -// grade/severity thresholds, WCAG map, conformance-verdict shape, and the -// report UI. -// --------------------------------------------------------------------------- - -export const PPTX = { - /** Feature flag — set PPTX_ENABLED=false to reject .pptx and hide it in the - * web UI (runtimeConfig.public.pptxEnabled). SAFE TO CHANGE: via env var. */ - ENABLED: process.env.PPTX_ENABLED !== "false", - - /** Canonical MIME type for .pptx (PresentationML). */ - MIME_TYPE: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - - /** Max UNCOMPRESSED bytes per ZIP part (zip-bomb guard) — same rationale - * and budget math as DOCX.MAX_UNCOMPRESSED_BYTES. SAFE TO CHANGE. */ - MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, - - /** Max slides analyzed; over the cap → rejected (CPU/heap bound, the - * MAX_PARAGRAPHS analogue). 2,000 slides is far beyond any real deck. - * SAFE TO CHANGE. */ - MAX_SLIDES: 2000, - - /** Max total shapes across all slides; over the cap → rejected. - * SAFE TO CHANGE. */ - MAX_SHAPES: 100_000, - - /** Max any-depth count of paragraphs () + text runs () across all - * slides; over the cap → rejected. A single shape can legally hold an - * unbounded txBody, and those text elements — not the shape containers — - * drive the per-run contrast walk and per-paragraph list walk, so - * MAX_SHAPES alone does not bound them. This is the MAX_PARAGRAPHS analogue - * for PowerPoint. SAFE TO CHANGE. */ - MAX_TEXT_ELEMENTS: 200_000, - - /** Wall-clock timeout (ms) per analysis; route maps timeout → 504. - * SAFE TO CHANGE. */ - ANALYSIS_TIMEOUT_MS: 20_000, - - /** - * PPTX category weights. PowerPoint maps onto the shared category IDs, - * except: - * - slide_titles is PowerPoint-specific (every slide needs a title); - * - reading_order is ACTIVE (title-first-in-shape-tree is machine-checkable) - * — it is permanently N/A for Word; - * - heading_structure / bookmarks are omitted (slide titles are the - * PowerPoint outline); form_accessibility is a not-assessed placeholder. - * Weights renormalize across applicable categories, as for PDF/DOCX N/A. - * SAFE TO CHANGE: same rules as DOCX.SCORING_WEIGHTS. - */ - SCORING_WEIGHTS: { - text_extractability: 0.05, - title_language: 0.14, - slide_titles: 0.18, - alt_text: 0.18, - reading_order: 0.1, - table_markup: 0.1, - color_contrast: 0.1, - list_structure: 0.07, - link_quality: 0.08, - }, -} as const; - -// --------------------------------------------------------------------------- -// XLSX (EXCEL) ANALYSIS -// --------------------------------------------------------------------------- - -export const XLSX = { - /** Feature flag — set XLSX_ENABLED=false to reject .xlsx and hide it in the - * web UI (runtimeConfig.public.xlsxEnabled). SAFE TO CHANGE: via env var. */ - ENABLED: process.env.XLSX_ENABLED !== "false", - - /** Canonical MIME type for .xlsx (SpreadsheetML). */ - MIME_TYPE: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - - /** Max UNCOMPRESSED bytes per ZIP part (zip-bomb guard) — same rationale as - * DOCX.MAX_UNCOMPRESSED_BYTES. SAFE TO CHANGE. */ - MAX_UNCOMPRESSED_BYTES: 30 * 1024 * 1024, - - /** Max worksheets analyzed; over the cap → rejected. SAFE TO CHANGE. */ - MAX_SHEETS: 200, - - /** Max total used-range cells (worksheet XML is the volume driver — this is - * the MAX_PARAGRAPHS analogue). Checked against the ACTUAL parsed `` - * cells (any depth, accumulated across sheets) — never the self-reported - * ``, which is attacker-controlled (see countCellsAnyDepth's - * doc comment in xlsxService.ts). Over the cap → rejected. SAFE TO CHANGE. */ - MAX_CELLS: 1_000_000, - - /** Max total drawing objects (pictures/charts) across all sheets; over the - * cap → rejected. Otherwise unbounded — limited only by - * MAX_UNCOMPRESSED_BYTES × MAX_SHEETS. Mirrors PPTX.MAX_SHAPES's DoS - * rationale. SAFE TO CHANGE. */ - MAX_DRAWING_OBJECTS: 100_000, - - /** Max total hyperlinks across all sheets; over the cap → rejected. Same - * unbounded-growth rationale as MAX_DRAWING_OBJECTS. SAFE TO CHANGE. */ - MAX_HYPERLINKS: 100_000, - - /** Max total defined tables across all sheets; over the cap → rejected. - * Worse than plain array growth: each <.../table> rel triggers a table-PART - * read + parse (a fan-out READ amplifier), bounded only by the 30 MB - * rels-part cap (~300k rels/sheet) × MAX_SHEETS. Pre-counted before any - * table part is read (see collectSheetContent). 10k far exceeds any - * legitimate workbook while bounding the fan-out. SAFE TO CHANGE. */ - MAX_TABLES: 10_000, - - /** Max total distinct drawing PARTS (relationships) across all sheets; - * over the cap → rejected. A legitimate sheet has ~1 drawing rel (Excel - * packs every drawing on a sheet into one drawingN.xml). Same fan-out - * READ-amplifier class as MAX_TABLES: each `/drawing` rel triggers a - * drawing-PART read + parse BEFORE MAX_DRAWING_OBJECTS can even be - * checked against that part's content, bounded only by the 30 MB - * rels-part cap (~300k rels/sheet) × MAX_SHEETS. Pre-counted before any - * drawing part is read (see collectSheetContent) — mirrors MAX_TABLES'S - * pre-count-before-read pattern exactly. - * RB3-3 [pre-merge re-audit]: tightened from 10,000 -> 1,000. The review - * benchmarked ~729ms/rel for a large, object-sparse drawing part, so only - * ~28 such rels reach the 20s ANALYSIS_TIMEOUT_MS — the 10,000 cap never - * engaged for that shape; it was a count-only bound, not a cost bound. - * 1,000 still comfortably exceeds any legitimate workbook (~1 rel/sheet - * × MAX_SHEETS=200) while shrinking the window; MAX_AUX_PART_BYTES below - * closes the rest of the gap on the SIZE dimension. SAFE TO CHANGE. */ - MAX_DRAWING_RELS: 1_000, - - /** Cumulative UNCOMPRESSED bytes actually read across every drawing + - * defined-table PART in a workbook (all sheets combined) — tracked in - * collectSheetContent's `counts.auxPartBytes` accumulator, checked right - * after each part is read, over the cap → rejected. - * RB3-3 [pre-merge re-audit]: closes a gap MAX_DRAWING_RELS/MAX_TABLES - * leave open even after being count-tightened: a HANDFUL of near-max-size - * (MAX_UNCOMPRESSED_BYTES, 30 MB), object-/row-sparse parts each pass the - * per-part cap individually and never approach the rel-COUNT cap, yet - * parsing each ~30 MB part still costs real wall-clock time (benchmarked - * ~729ms for one such drawing part) — few enough parts that the count cap - * doesn't engage before the 20s ANALYSIS_TIMEOUT_MS eventually would. This - * budget fails fast on the SIZE dimension instead, independent of count: - * ~1.6x one MAX_UNCOMPRESSED_BYTES part — room for one legitimate - * full-sized part plus incidental small ones, but not a second full-sized - * one. A legitimate workbook's drawing/table XML is KB-scale — nowhere - * close. SAFE TO CHANGE. */ - MAX_AUX_PART_BYTES: 48 * 1024 * 1024, - - /** Wall-clock timeout (ms) per analysis; route maps timeout → 504. - * SAFE TO CHANGE. */ - ANALYSIS_TIMEOUT_MS: 20_000, - - /** - * XLSX category weights. Excel maps onto the shared category IDs, except: - * - sheet_names is Excel-specific (no default "Sheet1" names); - * - title_language scores on the title alone (Excel stores no document - * language — the gate lists 3.1.1 as not assessed); - * - table_markup carries the most weight: data as real table objects with - * header rows is THE Excel accessibility fundamental; - * - heading_structure / reading_order / list_structure / bookmarks are - * omitted; form_accessibility is a not-assessed placeholder. - * SAFE TO CHANGE: same rules as DOCX.SCORING_WEIGHTS. - */ - SCORING_WEIGHTS: { - text_extractability: 0.05, - title_language: 0.12, - sheet_names: 0.18, - table_markup: 0.25, - alt_text: 0.18, - color_contrast: 0.12, - link_quality: 0.1, - }, -} as const; - -// --------------------------------------------------------------------------- -// OOXML (DOCX/PPTX/XLSX) SHARED ZIP-PACKAGE LIMITS -// --------------------------------------------------------------------------- -// Aggregate limits enforced once per package, right after JSZip.loadAsync and -// before any part is read — shared by the docx/pptx/xlsx extractors via -// assertZipWithinLimits() in services/ooxml.ts. The per-format -// MAX_UNCOMPRESSED_BYTES constants above bound any ONE part; they say nothing -// about the SUM across every part a package can legally contain (styles, -// dozens of slides/sheets, media, drawings, tables, rels, theme, core/app -// props). A zip built from many separately-legal-sized parts can still cost -// gigabytes of cumulative decompression across a single analysis, and a zip -// with an enormous number of tiny entries costs real CPU/memory just parsing -// JSZip's central directory, before any part is ever read. These two checks -// close both gaps and apply to every OOXML format uniformly. -// -// SAFE TO CHANGE: Yes for both values — pick values comfortably above any -// real-world Word/PowerPoint/Excel document; see each constant's note. -// --------------------------------------------------------------------------- - -export const OOXML = { - /** - * Maximum number of entries (files + directories) in the ZIP central - * directory. Real documents rarely exceed a few hundred parts even with - * many embedded images; 10,000 leaves generous headroom while bounding a - * "many tiny files" package designed to cost CPU/memory in JSZip's own - * central-directory parse before any content is even read. - * - * SAFE TO CHANGE: Yes. - */ - MAX_ZIP_ENTRIES: 10_000, - - /** - * Maximum SUM of every entry's declared uncompressed size (bytes) across - * the whole package. Checked once, right after JSZip.loadAsync, against - * the ZIP central directory's declared sizes (cheap — no decompression - * happens yet). Each per-format MAX_UNCOMPRESSED_BYTES (30 MB) already - * bounds any single part; this bounds the total across ALL parts, so a - * package built from many separately-legal-sized parts can't add up to - * an unbounded decompression bill. 512 MB is ~17x one full-sized part — - * comfortably above any legitimate Word/PowerPoint/Excel file (whose real - * total is normally single-digit MB to tens of MB even with heavy - * embedded media) while still bounding the aggregate. - * - * Declared sizes are attacker-controlled metadata (same caveat as - * readCapped's fast-reject check in ooxml.ts) — this is a cheap - * fast-fail, not the only guard; readCapped's streaming per-part cap - * remains the authoritative defense against a forged declared size. - * - * SAFE TO CHANGE: Yes. - */ - MAX_TOTAL_UNCOMPRESSED_BYTES: 512 * 1024 * 1024, -} as const; - -// --------------------------------------------------------------------------- -// PDF ANALYSIS LIMITS -// --------------------------------------------------------------------------- -// Operational limits for the PDF analysis pipeline. These protect the server -// from resource exhaustion and define category-specific behavior thresholds. -// -// SAFE TO CHANGE: Yes for all values, but read the notes on each. -// --------------------------------------------------------------------------- - -export const ANALYSIS = { - /** - * Maximum file upload size in megabytes. - * - * Enforced in three places (all must agree): - * 1. multer `limits.fileSize` in uploadMiddleware.ts - * 2. nginx `client_max_body_size` (set to this + 10MB headroom for headers) - * 3. Frontend file picker validation (immediate user feedback) - * - * SAFE TO CHANGE: Yes — but increasing above 50MB on a 4GB droplet risks - * OOM kills during concurrent uploads. If you increase this, also increase - * the nginx `client_max_body_size` in the Forge nginx config. - */ - MAX_FILE_SIZE_MB: 15, - - /** - * QPDF subprocess timeout in milliseconds. - * If QPDF hasn't finished parsing within this window, the process is killed - * and the API returns HTTP 504. - * - * SAFE TO CHANGE: Yes — increase if legitimate complex PDFs are timing out. - * Decrease if you want faster failure on adversarial inputs. 30s is a - * reasonable default; most PDFs finish in under 5s. - */ - QPDF_TIMEOUT_MS: 30_000, - - /** - * Maximum stdout buffer for QPDF JSON output, in bytes. - * QPDF's `--json` output can be very large for PDFs with deep structure - * trees or many objects. If the output exceeds this, execFileSync throws. - * - * SAFE TO CHANGE: Yes — increase if you see "maxBuffer exceeded" errors - * on legitimate PDFs. 50MB handles most documents; very complex government - * reports with thousands of tagged elements may need more. - */ - QPDF_MAX_BUFFER: 50 * 1024 * 1024, - - /** - * Wall-clock cap for the pdfjs extraction pass, in milliseconds. - * Unlike QPDF (a subprocess with its own timeout), pdfjs runs in-process, - * so a pathological PDF — millions of operators, a huge page count — can - * otherwise pin one of the MAX_CONCURRENT_ANALYSES slots indefinitely. On - * timeout the analysis is abandoned (HTTP 504) and the slot is freed so a - * single adversarial upload can't starve the queue. - * - * SAFE TO CHANGE: Yes — raise if legitimate large documents time out; - * lower for faster failure on adversarial inputs. 60s comfortably covers - * real government reports while bounding abuse. - */ - PDFJS_TIMEOUT_MS: 60_000, - - /** - * Maximum number of PDFs being analyzed simultaneously. - * Implemented as a semaphore in pdfAnalyzer.ts. Requests beyond this limit - * wait in a queue (or return 503 if the queue is also full). - * - * SAFE TO CHANGE: Yes — but on a 4GB droplet, 2 is the safe maximum. - * Each analysis can consume 50MB+ in memory (multer buffer + QPDF process). - * Increase only if you upgrade the droplet's RAM. - */ - MAX_CONCURRENT_ANALYSES: 2, - - /** - * Minimum page count to require bookmarks/outlines. - * Documents with fewer pages than this score N/A on the Bookmarks category - * instead of being penalized for missing bookmarks. - * - * SAFE TO CHANGE: Yes — WCAG doesn't specify an exact threshold. 10 is - * conservative. Some organizations use 4 or 5 pages. - */ - BOOKMARKS_PAGE_THRESHOLD: 10, - - /** - * Reading order: fraction of out-of-order MCIDs that triggers a score - * reduction. If more than this fraction of content items are out of - * sequence relative to the page content stream, the reading order score - * drops from 100 to 50. - * - * SAFE TO CHANGE: Yes — increase to be more lenient (e.g., 0.30 = allow - * 30% out-of-order before penalizing), decrease to be stricter. - */ - READING_ORDER_DISORDER_THRESHOLD: 0.2, -} as const; - -// --------------------------------------------------------------------------- -// AUTHENTICATION -// --------------------------------------------------------------------------- -// Controls for the OTP-based auth system. These values are also referenced -// in the auth flow description (doc 00, Section 3) and rate limiting. -// -, "i"); + const escaped = domains.map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp(`^[^@\\s]+@(?:[a-z0-9-]+\\.)*(?:${escaped.join("|")})$`, "i"); } export const AUTH = { From 600476cd2d46ae8134c2a7f518fd0a4722947a66 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:56:20 -0700 Subject: [PATCH 05/31] Test explicit and fail-closed URL allowlists --- apps/api/src/__tests__/urlPolicy.test.ts | 37 +++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) 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(); }); }); From b415d50de11abb1b5d32c9c606e5b8dae53fe227 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:56:22 -0700 Subject: [PATCH 06/31] Use deployment-neutral URL policy fixtures --- apps/api/src/__tests__/analyze-url.test.ts | 71 +++++++++++++--------- 1 file changed, 41 insertions(+), 30 deletions(-) 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"); From b797abe20564390fb90dc12e604b63250085a49e Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:56:25 -0700 Subject: [PATCH 07/31] Test portable fail-closed email domain configuration --- apps/api/src/__tests__/auth.test.ts | 73 ++++++++++------------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts index 09dcc7b..a803136 100644 --- a/apps/api/src/__tests__/auth.test.ts +++ b/apps/api/src/__tests__/auth.test.ts @@ -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); }); }); From e0be66d2e2fc157e1798a72df7533e93e96fbead Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:58:48 -0700 Subject: [PATCH 08/31] Make scoring descriptions deployment-neutral --- packages/shared/src/scoring.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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. // From c1651c9b21736db6fd7c722b750404b70350aa6d Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:58:50 -0700 Subject: [PATCH 09/31] Make OTP email branding portable --- apps/api/src/mailer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/mailer.ts b/apps/api/src/mailer.ts index 860c517..0857a3c 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). @@ -61,7 +61,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:

From afbccfed443e4238115c9c0c169e0e54d6cb7d04 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:58:52 -0700 Subject: [PATCH 10/31] Fail clearly when publication endpoint is unconfigured --- apps/cli/src/lib/graphql.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/lib/graphql.ts b/apps/cli/src/lib/graphql.ts index 8ad7682..380a656 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; @@ -33,6 +32,12 @@ export interface Publication { } export async function fetchPublications(): Promise { + if (!PUBLIST.GRAPHQL_ENDPOINT) { + throw new Error( + "PUBLIST_GRAPHQL_ENDPOINT is required when running the publist command.", + ); + } + const all: Publication[] = []; let offset = 0; From f015be6671b5e6c349aa5b18bbbd1c188cdd7457 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:58:54 -0700 Subject: [PATCH 11/31] Rename CLI package for AccessForge --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From ac8e4a2cb70d64a7caf27f7042f2de52a5ee0689 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:58:56 -0700 Subject: [PATCH 12/31] Run renamed AccessForge CLI test suite --- scripts/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); From ed34fb3c1184d5fb354e93f31c989da2c940a4ba Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:59:22 -0700 Subject: [PATCH 13/31] Document portable production configuration --- apps/api/.env.example.production | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) 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= From 8fde693513ac20ccda19c3d9349737b4b04d0ee1 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:59:24 -0700 Subject: [PATCH 14/31] Document portable local configuration --- apps/api/.env.example.local | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) 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= From 152c00b66a1d7f272ae782496c74757546f7e41e Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:59:26 -0700 Subject: [PATCH 15/31] Use AccessForge production app name --- apps/web/.env.example.production | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 898029be0da2ca0d595cda6548f9a7e8bc2226f7 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:59:27 -0700 Subject: [PATCH 16/31] Document complete AccessForge deployment configuration --- .env.example | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 - From 09a39b6a88b46a10227e15dddf5b44b17bf2275f Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 11:59:30 -0700 Subject: [PATCH 17/31] Remove Illinois-specific public metadata --- apps/web/nuxt.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index ffb7e37..fcac239 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -17,7 +17,7 @@ const appName = BRANDING.APP_SHORT_NAME; const orgName = BRANDING.ORG_NAME; const orgUrl = BRANDING.ORG_URL; const appDesc = - "Upload a PDF, Word, PowerPoint, or Excel document and get an instant accessibility score across WCAG 2.2 (and 2.1) Level AA, ADA Title II, and Illinois IITAA categories with detailed findings and remediation guidance."; + "Upload a PDF, Word, PowerPoint, or Excel document and get an instant WCAG-aligned accessibility score, detailed findings, manual-review indicators, and remediation guidance."; const datePublished = "2025-03-06"; // Derived from the last commit's date at build time, so this can't silently @@ -90,7 +90,7 @@ export default defineNuxtConfig({ "PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) accessibility scoring across WCAG 2.2 Level AA categories", "Instant A-F grading with severity levels", "Detailed findings with remediation guidance", - "WCAG 2.2 / 2.1 AA, ADA Title II, and Illinois IITAA compliance checking", + "WCAG 2.2 / 2.1 AA checks with explicit manual-review indicators", "Export reports as text, HTML, Markdown, or JSON", "Shareable report links", "Machine-readable JSON with WCAG mappings for LLM consumption", @@ -132,7 +132,7 @@ export default defineNuxtConfig({ { name: "author", content: orgName }, { name: "keywords", - content: "document accessibility, WCAG 2.2, ADA Title II, IITAA, accessibility audit", + content: "document accessibility, WCAG 2.2, ADA Title II, PDF accessibility, accessibility audit", }, ], link: [ From 6676cadb22b373f84d9460ed68a540ddc2a3d9a2 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:00:17 -0700 Subject: [PATCH 18/31] Forward portable AccessForge configuration to container --- compose.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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: - From a2b5c7dc9ec8dc254645e61ccb8ac5fffb1c8d7f Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:00:19 -0700 Subject: [PATCH 19/31] Use AccessForge process names and forward configuration --- ecosystem.config.cjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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", From 28975ba775661dec89f8d1a5476968522f698ef5 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:00:59 -0700 Subject: [PATCH 20/31] Document AccessForge portable deployment defaults --- README.md | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) 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 From 59c21d52a67bdc76daa42135bfce41e06b719397 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:01:01 -0700 Subject: [PATCH 21/31] Make machine-readable product description deployment-neutral --- apps/web/public/llms.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index db4093c..1fcd187 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -2,7 +2,7 @@ > 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 From 3eb7bbbef54841571a770a5dd297b71b08b436dc Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:01:03 -0700 Subject: [PATCH 22/31] Make standards and retention copy organization-neutral --- .../dataRetention/Section12Standards.vue | 82 ++++++++----------- 1 file changed, 35 insertions(+), 47 deletions(-) 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 @@ From 39e2a18cfb45bebdd6b308dcd3db68e3b007c00a Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:01:56 -0700 Subject: [PATCH 23/31] Make full machine-readable docs deployment-neutral --- apps/web/public/llms-full.txt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index 0c17df9..5b85f2d 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -14,7 +14,7 @@ PDFs can optionally be auto-remediated (tag structure added via QPDF + OpenDataL Website: http://localhost:5102 Source: https://github.com/mycomind4-arch/AccessForge -FAQs: https://accessibility.icjia.app +FAQs: deployment-specific Organization: AccessForge (https://github.com/mycomind4-arch/AccessForge) --- @@ -137,7 +137,6 @@ Each category also carries an individual severity label, derived from that categ - **WCAG 2.2 Level AA** — the default, operative standard (a strict superset of WCAG 2.1 AA; every machine-checkable criterion carried forward unchanged from 2.1 into 2.2, so automated results are identical either way). WCAG 2.2 adds six new Level A/AA success criteria beyond 2.1, all interactive/manual (e.g., target size, dragging movements, redundant entry) — none are automatically failed by this tool; where one could plausibly apply (mainly to interactive PDF form fields) it is surfaced as "not assessed — manual review," never as an automated failure. A deployment can revert its displayed standard to WCAG 2.1 via the `WCAG_VERSION=2.1` environment variable. - **ADA Title II** — the U.S. Department of Justice's digital accessibility rule for state and local government, requiring WCAG 2.1 AA; effective April 2026. -- **Illinois IITAA 2.1** (§E205.4) — the Illinois Information Technology Accessibility Act; frames final non-web document accessibility through WCAG 2.1 AA, the legal minimum this tool's default WCAG 2.2 auditing exceeds. - **Section 508** — the U.S. federal accessibility standard, aligned with WCAG 2.0/2.1 AA. - **PDF/UA** (ISO 14289-1) — referenced only for PDF (see "PDF-only signals" above); not applicable to Word, PowerPoint, or Excel reports. @@ -169,7 +168,7 @@ This is the schema of the downloadable **JSON export** (the "Export as JSON" act }, "scoreProfiles": { "strict": { - "label": "Strict semantic score (WCAG + IITAA §E205.4)", + "label": "Strict semantic score (WCAG-aligned)", "description": "...", "overall": 85, "grade": "B", @@ -227,7 +226,6 @@ This is the schema of the downloadable **JSON export** (the "Export as JSON" act "standards": [ "WCAG 2.2 Level AA", "ADA Title II (effective April 2026)", - "Illinois IITAA 2.1 (§E205.4)", "Section 508", "PDF/UA (ISO 14289-1)" ], @@ -251,7 +249,7 @@ Notes on this schema: ## API Reference -Endpoints marked "Auth: session or PAT" accept either a browser session cookie or an `Authorization: Bearer fap_xxx` personal access token. The public deployment currently runs with authentication disabled (anonymous mode), so in practice no credential is required there; a deployment with `AUTH.REQUIRE_LOGIN=true` enforces it. +Endpoints marked "Auth: session or PAT" accept either a browser session cookie or an `Authorization: Bearer fap_xxx` personal access token. Authentication is deployment-configurable; `AUTH_REQUIRE_LOGIN=true` enforces it. ### POST /api/analyze Upload a document for accessibility analysis. @@ -266,7 +264,7 @@ Audit a document by URL instead of upload. Returns the same result shape as `POS - Content-Type: application/json - Body: `{ "url": "https://..." }` - Auth: session or PAT -- The URL must resolve to a public host. Anonymous callers are restricted to an ICJIA / Illinois state government allowlist (extendable via the `ANALYZE_URL_ALLOWED_HOSTS` environment variable); a request bearing a valid privileged bearer token bypasses the allowlist and may fetch any public URL. Either way, a private/reserved-IP SSRF block (localhost, `*.local`/`*.internal`, RFC1918 ranges, link-local addresses) is enforced unconditionally, re-checked on every redirect hop. +- The URL must resolve to a public host. URL audits are disabled until `ANALYZE_URL_ALLOWED_HOSTS` explicitly lists approved public hosts; a request bearing a valid privileged bearer token may fetch any public URL. Either way, a private/reserved-IP SSRF block (localhost, `*.local`/`*.internal`, RFC1918 ranges, link-local addresses) is enforced unconditionally, re-checked on every redirect hop. - Limits: 15 MB fetched content, 30 s fetch timeout, same rate limit as `/api/analyze`. ### POST /api/audit-url From 6d0015bc704e27edb738acf3bedda5ee53473445 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:01:58 -0700 Subject: [PATCH 24/31] Make technical details deployment-neutral --- apps/web/app/pages/technical-details.vue | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 {

Date: Thu, 16 Jul 2026 12:02:00 -0700 Subject: [PATCH 25/31] Advance AccessForge near-term roadmap --- PRODUCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 3902f6b4af8c0b6940d513ae0b2587a014388299 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:02:02 -0700 Subject: [PATCH 26/31] Document environment-driven login configuration --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From ecd9f5a79d4a3ea3fdb6dc52b47503c06592b4e3 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:02:22 -0700 Subject: [PATCH 27/31] Require a verified sender for production authentication --- apps/api/src/mailer.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/api/src/mailer.ts b/apps/api/src/mailer.ts index 0857a3c..2e37435 100644 --- a/apps/api/src/mailer.ts +++ b/apps/api/src/mailer.ts @@ -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})`); From 818d4f3d931f91084f19fa2af57bd8d6ead4530b Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:02:24 -0700 Subject: [PATCH 28/31] Test required production SMTP sender --- apps/api/src/__tests__/mailer.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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(() => {}); From d1816afc0db913bea5a482e1bce5ba86be27ffb4 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:03:50 -0700 Subject: [PATCH 29/31] Remove unused auth test import --- apps/api/src/__tests__/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts index a803136..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"; From 046044a2c1fb344fc8dc91655fc18717b235d8c3 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:07:38 -0700 Subject: [PATCH 30/31] Make publication endpoint injectable for testing --- apps/cli/src/lib/graphql.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/lib/graphql.ts b/apps/cli/src/lib/graphql.ts index 380a656..7f98d70 100644 --- a/apps/cli/src/lib/graphql.ts +++ b/apps/cli/src/lib/graphql.ts @@ -31,8 +31,10 @@ export interface Publication { tags: string[] | null; } -export async function fetchPublications(): Promise { - if (!PUBLIST.GRAPHQL_ENDPOINT) { +export async function fetchPublications( + endpoint = PUBLIST.GRAPHQL_ENDPOINT, +): Promise { + if (!endpoint) { throw new Error( "PUBLIST_GRAPHQL_ENDPOINT is required when running the publist command.", ); @@ -55,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 }), From 65b9744d7b72a86b5af961b8eeae954f3338e1c0 Mon Sep 17 00:00:00 2001 From: mycomind4-arch Date: Thu, 16 Jul 2026 12:07:40 -0700 Subject: [PATCH 31/31] Cover configured and missing publication endpoints --- apps/cli/src/__tests__/graphql.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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); });