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

@@ -30,28 +30,28 @@ being developed on top of that foundation.
> legal compliance. Human review and assistive-technology testing remain part
> of a defensible accessibility program.
-A web tool that **audits** PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) accessibility — and **(optionally) auto-remediates** PDFs — against [WCAG 2.2 AA](https://www.w3.org/WAI/WCAG22/quickref/) (a strict superset of [WCAG 2.1 AA](https://www.w3.org/WAI/WCAG21/quickref/), the legal minimum under [IITAA 2.1 §E205.4](https://doit.illinois.gov/initiatives/accessibility.html) and [ADA Title II](https://www.ada.gov/resources/title-ii-rule/)), and [Illinois IITAA 2.1](https://doit.illinois.gov/initiatives/accessibility.html) — all on infrastructure you control, with no AI and no per-document fees. To revert to WCAG 2.1 labels: set `WCAG_VERSION=2.1` and redeploy (API reverts on restart; web UI on rebuild).
+A self-hosted web tool that **audits** PDF, Word (.docx), PowerPoint (.pptx), and Excel (.xlsx) accessibility — and **optionally auto-remediates** PDFs — using WCAG-aligned checks, detailed findings, and explicit manual-review indicators. Set `WCAG_VERSION=2.1` to use WCAG 2.1 labels; otherwise WCAG 2.2 is the default. Automated results are diagnostic and are not a legal compliance or certification claim.
## What it does
| | Feature | Detail |
|---|---------|--------|
| **9** | WCAG categories audited | Each document (PDF, Word, PowerPoint, or Excel) scored across the WCAG-aligned categories that apply to its format (up to 9) — a weighted 0–100 score (A–F grade) plus a separate, binary pass/fail **WCAG 2.2 conformance verdict**. |
-| **F → A** | Auto-remediation (optional) | Tag untagged PDFs in seconds: qpdf → [OpenDataLoader](https://github.com/opendataloader-project/opendataloader-pdf) → [veraPDF](https://verapdf.org/). Output is rejected if it regresses the score. Manual review still recommended for IITAA compliance. |
-| **PDF/UA-1** | Standards aligned | WCAG 2.2 AA (superset of 2.1 AA), ADA Title II (April 2026), Illinois IITAA 2.1, PDF/UA-1 via veraPDF. Full lifecycle audit trail with `fs.stat`-verified deletion events for compliance reporting. |
+| **F → A** | Auto-remediation (optional) | Tag untagged PDFs in seconds: qpdf → [OpenDataLoader](https://github.com/opendataloader-project/opendataloader-pdf) → [veraPDF](https://verapdf.org/). Output is rejected if it regresses the score. Manual accessibility review is still required. |
+| **PDF/UA-1** | Standards aligned | WCAG 2.2 AA-aligned checks (including the 2.1 criteria), ADA Title II context, and optional PDF/UA-1 signals via veraPDF. Full lifecycle audit trail with `fs.stat`-verified deletion events for compliance reporting. |
| **0** | Files retained | Audit: in-memory only, gone in seconds. Remediation: output deleted on first download or 30-minute TTL, then verified absent. |
| **$0** | No AI, no third-party APIs | Every step runs on your own server. No data sent to vision models, hosted AI services, or commercial PDF/Office SDKs. |
-| **100%** | Open source | Apache 2.0 / MIT / MPL toolchain. No per-document fees, no SDK licensing. Designed for state agencies that need control over their pipeline. |
+| **100%** | Open source | Apache 2.0 / MIT / MPL toolchain. No per-document fees, no SDK licensing. Designed for organizations that need control over their pipeline. |
| **3** | Files per batch | Upload up to 3 files (PDF, Word, PowerPoint, or Excel) at once; per-tab remediation for PDFs. `POST /api/analyze-url` for programmatic auditing of public documents. |
| **4** | Export formats | Text / HTML / Markdown / JSON report exports. 1-year shareable links (no login required to view). |
Auto-remediation is **disabled by default** — set `REMEDIATION_ENABLED=true` in your environment to enable. Architectural details in [docs/archive/pdf-remediation-integration-plan.md](docs/archive/pdf-remediation-integration-plan.md); the Phase 1 follow-on (interactive alt-text walkthrough) is specced in [docs/archive/pdf-remediation-alt-text-walkthrough-spec.md](docs/archive/pdf-remediation-alt-text-walkthrough-spec.md).
-The intended workflow is: **upload → review findings → either auto-remediate or fix at the source (Word, InDesign, etc.) and re-export → re-upload to verify.** Manual review remains essential for full IITAA compliance regardless of which path is taken — the tool's job is to find issues and reduce the manual remediation surface, not replace human review.
+The intended workflow is: **upload → review findings → either auto-remediate or fix at the source (Word, InDesign, etc.) and re-export → re-upload to verify.** Manual review remains essential regardless of which path is taken — the tool's job is to find issues and reduce the manual remediation surface, not replace human review.
## Contents
-New here? The live tool is at **[audit.icjia.app](https://audit.icjia.app)**; this README is the technical companion. Jump to:
+New here? Start with the local Docker deployment in [PRODUCT.md](PRODUCT.md); this README is the technical companion. Jump to:
- **Overview** — [What it does](#what-it-does) · [Scoring rubric](#scoring-rubric)
- **Run it** — [Quick Start](#quick-start) · [Authentication](#authentication) · [Configuration](#configuration) · [Deployment](#deployment)
@@ -119,26 +119,27 @@ pnpm rebrand # Regenerate static files after changing BRANDING in audit.confi
## Authentication
-Authentication is **off by default**. The app can be used without any login, email provider, or credentials. This is controlled by a single toggle in `audit.config.ts`:
+Authentication is **off by default**. Enable email-OTP login through environment configuration:
-```ts
-export const AUTH = {
- REQUIRE_LOGIN: false, // ← set to true to enable OTP authentication
- // ...
-};
+```env
+AUTH_REQUIRE_LOGIN=true
+ALLOWED_DOMAINS=example.org,partner.example
+JWT_SECRET=replace-with-a-high-entropy-secret
```
-### With auth disabled (`REQUIRE_LOGIN: false` — default)
+When login is enabled in production, configure a verified SMTP sender and credentials as shown in `apps/api/.env.example.production`.
+
+### With auth disabled (`AUTH_REQUIRE_LOGIN=false` — default)
- Users go straight to the upload page — no login screen
- No email provider or SMTP credentials needed
- No audit history is recorded (no user identity to associate with analyses)
- All security protections (rate limiting, file validation, CORS) remain active
-### With auth enabled (`REQUIRE_LOGIN: true`)
+### With auth enabled (`AUTH_REQUIRE_LOGIN=true`)
- Users must authenticate via a **6-digit one-time password (OTP)** sent to their email
-- Only `illinois.gov` email addresses are accepted (configurable via `AUTH.ALLOWED_EMAIL_REGEX`)
+- Only domains listed in `ALLOWED_DOMAINS` are accepted; exact domains and their subdomains are allowed
- Sessions last 72 hours via JWT in an httpOnly cookie — no passwords stored
- All analyses are logged with the authenticated user's email for audit history
- **Requires an email provider** — the app needs to send OTP codes (see below)
@@ -152,20 +153,14 @@ When authentication is enabled, the app sends one-time passcodes via email. This
| Mailgun (default) | [docs/archive/07-mailgun-integration.md](docs/archive/07-mailgun-integration.md) |
| SMTP2GO | [docs/archive/06-smtp2go-integration.md](docs/archive/06-smtp2go-integration.md) |
-The provider is controlled in `audit.config.ts` → `EMAIL.PROVIDER`. Credentials go in `apps/api/.env`:
+The provider and SMTP connection are controlled by environment variables. Credentials go in `apps/api/.env`:
```env
SMTP_USER=your-smtp-login
SMTP_PASS=your-smtp-password
```
-**To switch providers**, change one line in `audit.config.ts`:
-
-```ts
-PROVIDER: "mailgun"; // ← change to 'smtp2go' to switch
-```
-
-Host and port are set automatically per provider.
+**To switch providers**, set `EMAIL_PROVIDER=mailgun` or `EMAIL_PROVIDER=smtp2go`; override `SMTP_HOST` and `SMTP_PORT` for another compatible relay.
**Dev note:** When running locally with auth enabled, OTP codes are printed to the API console — no email credentials needed for development.
@@ -1296,7 +1291,7 @@ Security reviews for prior releases were not yet captured in this format. Going
## Changelog
-See [CHANGELOG.md](CHANGELOG.md) for a full list of changes by version, or view [releases on GitHub](https://github.com/ICJIA/file-accessibility-audit/releases).
+See [CHANGELOG.md](CHANGELOG.md) for a full list of changes by version, or view [releases on GitHub](https://github.com/mycomind4-arch/AccessForge/releases).
## License
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);
});