A Canvas LMS LTI 1.3 connector for Equalify Reflow. The connector launches inside Canvas, watches courses for new PDFs, sends them to the upstream Reflow Core HTTP API for accessibility conversion, and publishes the resulting accessible HTML back into Canvas as Pages — with a faculty review workflow, a dial-badge overlay over Canvas's own file UI, and a full alt-format catalogue (accessible HTML, tagged PDF, ePub, Braille, audio, translation, plain text, Markdown).
Canvas LMS Reflow Core (upstream)
↕ ↑
↕ LTI 1.3, OAuth, REST HTTP API
↕ │
reflow-canvas-lti ───────────────────────┘
(this repo)
The connector owns the Canvas-side experience and state; Reflow Core owns
the document pipeline. The two services talk over HTTP — the connector
imports zero Reflow Core Python. See
docs/ARCHITECTURE.md for the full picture and
docs/REFLOW_API.md for the exact endpoints the
connector consumes.
- Faculty — upload a PDF, get an accessible Canvas Page in minutes, review before students see it, fix anything with an in-browser editor, decide what counts as a waivable accessibility finding.
- Students — open the row's dial, get the accessible version in the format that works for them (HTML, Braille, audio, translated to their L1, etc.).
- Operators / Canvas admins — runbook-driven deployment, see
OPERATIONS.md.
- Accessibility dial on every PDF row in Canvas Files, with two honest numbers: PDF/UA-1 score (veraPDF) on the original PDF, and a WCAG structural-check score on the generated HTML. These measure different things and the UI labels them as such — there is no before/after framing.
- Pending-scan marker on rows the watcher hasn't picked up yet, so faculty isn't left wondering whether processing started.
- In-modal alt-format menu grouped by purpose: Read, Listen & translate, Document formats. 10 generated formats; the original source PDF stays in Canvas Files where faculty uploaded it.
- Per-document review screen (
/canvas/review/{job}) — side-by-side live PDF preview + accessible HTML (or live Canvas Page once published). Approve, reject, pull-back-to-draft, unpublish. - Inline HTML editor for the accessible version — fix table semantics, alt text, headings, etc.; saves become the source of truth for every downstream format.
- PII review queue surfaced in the LTI tool ("Accessible Documents") with badges for "PII review" vs "Accessibility review". PII gates block the rest of the pipeline.
- WCAG publication gate (opt-in via
REQUIRE_WCAG_GATE=true) — the approval modal renders a 4-item visual-inspection checklist plus per-rule waivers for any automated WCAG errors. Faculty can't publish with unwaived errors when the gate is on; nothing changes when it's off.
| Format | Tool | Notes |
|---|---|---|
| Accessible HTML | mistune + sanitize | Canonical source for every other format |
| HTML with math | + MathJax (mhchem extension) | LaTeX and chemistry markup render to MathML at view time |
| Plain text | tag-strip | UTF-8, no markup |
| Markdown | passthrough from Reflow | The canonical Reflow output |
| Tagged PDF | WeasyPrint (pdf/ua-1) | Born-digital input: produces a real structure tree (StructTreeRoot, MarkInfo, language metadata). Image-only input: falls back to ocrmypdf. Math renders as inline SVG via matplotlib's mathtext so equations are visible in the PDF and the LaTeX source is preserved as the figure alt for screen readers. |
| Searchable PDF | ocrmypdf | Image-only scans get an OCR text layer |
| ePub | ebooklib | EPUB3 |
| Audio (MP3) | Amazon Polly | Requires AWS_DEFAULT_REGION + IAM credentials with polly:SynthesizeSpeech |
| Translate | Anthropic Claude (Sonnet 4.5) | Requires ANTHROPIC_API_KEY. Prompt explicitly preserves LaTeX math and \ce{} chemistry markup verbatim. |
| Braille (BRF) | liblouis (lou_translate) + in-house BANA layout |
Structured transcription, not flat text: UEB contracted braille for prose, with a maths code (Nemeth where available, otherwise UEB Technical) applied only to expressions. Centred headings, 3-1 paragraphs, 1-3 lists, figure descriptions with the caption first, print page numbers, 40×25 pages with form feeds, words never split. See docs/BRAILLE.md. |
- LTI 1.3 launch — OIDC handshake, signed JWT validation, public JWKS, tool-config JSON for Canvas Developer Key paste-in.
- Per-instructor Canvas OAuth2 — required because Canvas Cloud's
general
/api/v1/*REST endpoints don't accept LTI Advantage service tokens. Tokens are encrypted at rest with AES-GCM (see Security below). - Canvas file watcher — discovers new PDFs and submits them to Reflow Core. Configurable per-course or multi-tenant across every registered LTI platform.
- Reflow bridge worker — polls Reflow Core for completion, renders Markdown → Canvas-safe HTML, extracts figures directly from the source PDF via PyMuPDF (cleaner than Reflow's S3 copies which carry a vision-model overlay), uploads figures into a course folder, creates or updates a Canvas Page.
- veraPDF integration — every source PDF audited against PDF/UA-1 on submission; failed rules surfaced inline in the alt-format modal.
- PII gate — when Reflow Core flags PII (Microsoft Presidio upstream), faculty see a CSRF-protected approval form in either the LTI tool's queue or the panorama overlay's modal.
- OAuth tokens encrypted at rest with AES-GCM (
connector/canvas/privacy.py). Key derivation falls back throughTOKEN_ENCRYPTION_KEY→CSRF_SECRET_KEY→ a constant; the connector logsCRITICALonce per process on the constant fallback so it's impossible to miss in prod logs. - CSRF on every state-changing POST. No exceptions; the panorama
overlay fetches a token via
/canvas/panorama/csrfon load. - Rate limiting per
(endpoint, user_id)on every state-changing POST. 30/min for approve/reject; 60/min for the auto-save editor; 10/min for PII decisions; 5/min for bulk approve. SeeOPERATIONS.md. - Trusted-origin allowlist (
CANVAS_ALLOWED_ORIGINS) blocks cross- origin state changes from anything that isn't your Canvas host. - Append-only audit log of every approve / reject / unpublish / PII decision, with retention controls.
- Startup secrets audit logs
CRITICALfor every production-required secret that's unset. Doesn't block boot; impossible to miss. - Redis persistence (AOF + RDB) on a named volume; container restart
no longer wipes faculty consent records or the audit log. Off-host
backup via
scripts/backup-redis.sh(cron-friendly, optional S3 upload). - Integration tests covering the LTI session + CSRF + rate-limit pipeline for the PII decision flow and the publish-approval flow.
cp .env.example .env
# Fill in REFLOW_API_BASE_URL, REFLOW_API_KEY, LTI_*, CANVAS_*.
# Generate the secrets:
docker compose run --rm connector python -m connector.tools.generate_keys >> .env
./scripts/generate_lti_keys.sh
# Check the configuration before booting — catches the common first-run
# mistakes (missing key pair, placeholder client id, trailing slash on
# LTI_PUBLIC_URL) with a readable message instead of a runtime failure.
docker compose run --rm connector python scripts/preflight.py
docker compose upVisit http://localhost:8000/health for liveness and
http://localhost:8000/lti/config.json for the JSON to paste into a
Canvas Developer Key (full walkthrough:
docs/CANVAS_SETUP.md).
For dev hot-reload, layer the override:
docker compose -f docker-compose.yml -f docker-compose.dev.yml upFor an end-to-end smoke test against a local Reflow Core, see
docs/PILOT_RUNBOOK.md.
The connector is one container plus a Redis it owns. Full detail —
image, secrets, networking, scaling, health checks — is in
docs/DEPLOY.md; the shape of it is:
# 1. On the host: clone, configure, generate keys
git clone https://github.com/oshrizak/reflow-canvas-lti.git
cd reflow-canvas-lti
cp .env.example .env # then fill it in — see docs/DEPLOY.md
./scripts/generate_lti_keys.sh
docker compose run --rm connector python -m connector.tools.generate_keys >> .env
# 2. Verify the configuration before it matters
docker compose run --rm connector python scripts/preflight.py
# 3. Register the tool in Canvas (creates + deploys the Developer Key,
# prints the LTI_CLIENT_ID and LTI_DEPLOYMENT_ID to paste into .env)
docker compose run --rm connector python scripts/provision_canvas.py --apply
# 4. Boot
docker compose up -dThree things decide whether this works on the first try:
LTI_PUBLIC_URLmust be the public HTTPS URL Canvas will reach, with no trailing slash. Canvas validates the redirect URI against it exactly.- Terminate TLS in front of the container. Canvas will not launch a tool over plain HTTP.
- An instructor must authorize the tool once per course before anything publishes. The watcher and bridge act as that user, not as the tool — Canvas Cloud does not accept LTI Advantage tokens for the REST API.
When something returns 403 or 401, read
docs/TROUBLESHOOTING.md before changing
scopes or keys. Several failures in this integration look like permission
problems and are not.
Every setting is documented in .env.example. Minimum
to boot:
REFLOW_API_BASE_URL+REFLOW_API_KEY— where Reflow Core is reachable and the X-API-Key it expects.LTI_ENABLED=trueplusLTI_ISSUER,LTI_CLIENT_ID,LTI_DEPLOYMENT_ID,LTI_PUBLIC_URL(from your Canvas Developer Key).CANVAS_API_URLandCANVAS_OAUTH_CLIENT_ID+CANVAS_OAUTH_CLIENT_SECRET(multi-tenant per-instructor OAuth).CANVAS_ALLOWED_ORIGINS— every Canvas host you'll serve from.
Required-for-production but optional-for-dev:
TOKEN_ENCRYPTION_KEY,CSRF_SECRET_KEY— generated viapython -m connector.tools.generate_keys.REQUIRE_WCAG_GATE=true— enforces the publication gate.ANTHROPIC_API_KEY— only if you're exposing Translate.AWS_DEFAULT_REGION+AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY— only if you're exposing Audio MP3 (Polly).
The connector logs a CRITICAL audit line on startup for every
production-required secret that's unset. See
OPERATIONS.md
for the complete checklist.
connector/
├── main.py FastAPI app + worker lifespan + startup secrets audit
├── config.py Settings (env-driven, pydantic-settings)
├── dependencies.py Shared Redis pool
├── lti/ LTI 1.3 handshake, JWKS, platform registry
├── canvas/ Canvas client, OAuth, alt-formats, state, privacy,
│ pdf_figures, math_render, verapdf_audit
├── api/ Canvas-facing routers (consent, OAuth, panorama, review)
├── workers/ canvas_watcher + reflow_bridge_worker
├── tools/ Operator CLIs (generate_keys, reprocess_figures, …)
├── utils/ Cross-cutting helpers (rate_limit, retry_helpers)
└── web/canvas_review/ Front-end (overlay JS, review HTML templates)
docs/ Architecture, setup, deploy, troubleshooting
scripts/
├── preflight.py Validate .env before boot
├── provision_canvas.py Create + deploy the Canvas LTI developer key
├── generate_lti_keys.sh LTI 1.3 key pair
└── backup-redis.sh Operator backup helper
tests/
├── unit/ Pure-logic tests
└── integration/ End-to-end LTI session + CSRF + rate-limit
OPERATIONS.md— operator runbook: secrets, backups, rate limits, breakage modes, recovery procedures.docs/ARCHITECTURE.md— component map, data flow, Redis key shape, multi-tenant model.docs/REFLOW_API.md— the HTTP contract with upstream Reflow Core.docs/CANVAS_SETUP.md— Canvas admin walkthrough: Developer Keys, scopes, placements.docs/DEPLOY.md— image, env, networking, health.docs/PILOT_RUNBOOK.md— end-to-end smoke test and the first-week failure modes.docs/BRAILLE.md— how BRF is produced, why prose and maths use different braille tables, and what a production manual requires of the output.docs/TROUBLESHOOTING.md— failure modes seen in production and how to tell them apart. Read this first when a Canvas call returns 403 or 401; several of them look like permissions problems and are not.CHANGELOG.md— released changes.
AGPL-3.0-or-later. Matches upstream Reflow.
Extracted from the equalify-reflow
Canvas-integration fork that ran the first CSU East Bay pilot. The
original Canvas implementation work was done by the contributors to
that fork.