Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions src/content/docs/vizably/account-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Account storage
description: Vizably keeps no database — a user's account lives in a GitHub repo or Drive folder they already own.
sidebar:
order: 3
order: 4
---

Vizably runs **no database of its own**. A signed-in user's entire account —
Expand Down Expand Up @@ -106,13 +106,39 @@ expected rather than exceptional.
store can load the account. The storage ACL *is* the account ACL. That is a
deliberate trade and it needs saying out loud in the UI, not just in docs.

Two other things must be disclosed in the interface rather than buried:

- GitHub's OAuth `repo` scope is all-or-nothing — it cannot be narrowed to a
single repository.
- Deleting a scan removes the file and refreshes the caches, but **GitHub
history may still contain the deleted blob** unless history is rewritten. Do
not claim permanence you cannot deliver.
One other thing must be disclosed in the interface rather than buried:
deleting a scan removes the file and refreshes the caches, but **GitHub
history may still contain the deleted blob** unless history is rewritten. Do
not claim permanence you cannot deliver.

## GitHub access is a GitHub App, not a plain OAuth scope

GitHub storage is authorized through a **GitHub App** (`GITHUB_APP_ID` +
`GITHUB_APP_PRIVATE_KEY`, installed per-account) rather than a classic OAuth
App with a `repo` scope. That is a deliberate choice: a classic `repo` scope
is all-or-nothing across every repository the user owns, while a GitHub App
installation can be scoped to the one repository Vizably actually needs —
narrower access, disclosed as such. The backend resolves the installation for
a given `owner/repo` via the Apps API before writing
(`backend/services/authService.js`).

## Endpoints and current gaps

The auth/storage API lives under `/api/auth/*`. `backend/README.md` in the
repository keeps the endpoint table current — read that rather than this page
for the exact routes, since this is the part of Vizably still changing
fastest. Two things worth knowing going in:

- **Google is not implemented yet.** `/api/auth/google` and its callback
return `501` today, and
[issue #111](https://github.com/codrlabs/vizably/issues/111) tracks
dropping Google sign-in from the near-term plan rather than finishing it —
treat the Drive side of this page as the target design, not current
behavior.
- **GitHub repository creation exists** (`POST /api/auth/storage/create`,
plus a name-availability check) in addition to the browse/validate/load
flow described above — the connect UI can create a new private repository
for a user who doesn't have one yet, not just pick from existing ones.

## Implementer checklist

Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/vizably/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Architecture
description: How Vizably is put together — the layers, the folders, and the contract between the two halves.
sidebar:
order: 2
order: 3
---

Vizably is two halves and one wire contract. The backend knows nothing about
Expand Down
140 changes: 140 additions & 0 deletions src/content/docs/vizably/getting-started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
title: Getting started
description: Clone, run and test Vizably locally — Docker or plain Node, in under 15 minutes.
sidebar:
order: 2
---

This gets you from `git clone` to a running app with a green test suite. It
assumes nothing beyond `git` and a browser.

## Prerequisites

One of:

- **Docker Desktop**, recommended — one install, no Node version juggling.
- **Node.js 24** and a recent npm. Check with `node -v`. CI and
`backend/package.json`'s `engines` field both pin 24; older versions are
not tested against.

You do not need Postgres or any cloud account to run the app locally. Puppeteer
and axe-core install with `npm install` in `backend/` (Puppeteer downloads its
own Chromium; the Docker image installs Alpine's system Chromium instead).

## Clone the repo

```bash
git clone https://github.com/codrlabs/vizably.git
cd vizably
```

The [Architecture](/vizably/architecture/) page covers the folder layout; the
repository's own `README.md` has the up-to-date directory tree.

## Set the two required secrets

The server **refuses to start** without `SESSION_SECRET` and `ENCRYPTION_KEY`
set — `backend/index.js` throws on boot if either is missing, even for local
development with no OAuth configured. This is a real requirement, not a
Phase-1 placeholder: sessions are a signed cookie
(`cookie-session`, not a server-side store), and that cookie has to be signed
with something.

```bash
cd backend
cp .env.example .env
openssl rand -base64 32 # run twice, paste one value each into
# SESSION_SECRET and ENCRYPTION_KEY in .env
```

Everything else in `.env.example` (the GitHub App credentials) is only needed
to exercise sign-in and saved scans — see
[Account storage](/vizably/account-storage/). Scanning a URL works without
them.

:::note[Docker users]
`docker-compose.yml` does not currently inject `SESSION_SECRET` or
`ENCRYPTION_KEY` into the backend container, so `docker compose up` fails at
boot on a fresh clone until you either add them to the compose file's
`environment:` block or otherwise get them into the container's environment.
Local Node picks up `backend/.env` automatically through `dotenv`.
:::

## Run the app

### Option A — Docker

```bash
docker compose up --build
```

First run takes a couple of minutes (pulling `node:22-alpine`, installing
dependencies in both containers). Once you see the frontend and backend both
report they're listening, open <http://localhost:5173>.

### Option B — local Node

Two terminals:

```bash
# Terminal 1 — backend (Express on :3000)
cd backend
npm install
npm run dev # nodemon, reloads on save

# Terminal 2 — frontend (Vite on :5173)
cd frontend
npm install
npm run dev # hot-reloads on save
```

Open <http://localhost:5173>.

## Smoke-test it

Every submission runs a real Puppeteer + axe-core scan — there is no mock
mode in the running app.

1. Open <http://localhost:5173>. Type a URL — bare domains work too
(`example.com`), see [URL normalization](/vizably/url-normalization/) —
and submit.
2. The scan takes a few seconds against the live page. You land on
`/results?url=...`: a score, severity badges, and findings grouped into
Visual Accessibility, Structure & Semantics and Multimedia, plus a "what's
good" list.
3. Click a finding to go to `/problem/:id` — root cause, offending markup,
fix steps and a WCAG reference.

Or verify the API directly:

```bash
curl http://localhost:3000/health

curl "http://localhost:3000/api/scan-results?url=https://example.com"
# real scan — expect several seconds

curl "http://localhost:3000/api/scan-results?url=http://127.0.0.1"
# 400 {"error":"Private/loopback hosts are not allowed"} — the SSRF guard
```

## Run the tests

```bash
cd backend && npm test # node:test + supertest
cd frontend && npm test:run # Vitest, single run
cd frontend && npm run lint
cd frontend && npm run build
```

These are what CI runs on every pull request. Run them once on a clean clone
so you know what green looks like before you make your first edit.

## Where to look next

1. [Architecture](/vizably/architecture/) — the layers and the folders.
2. [Account storage](/vizably/account-storage/) — the portable-account model
behind sign-in and saved scans.
3. [Scanning](/vizably/scanning/) — how a submitted URL turns into a report.
4. The repository's own `README.md` and `backend/README.md` for the
authoritative, always-current directory layout and environment variable
reference.
12 changes: 5 additions & 7 deletions src/content/docs/vizably/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,12 @@ hosting, no lock-in, and an account that is portable across devices.

## Read next

- [Getting started](/vizably/getting-started/) — clone it, run it, test it.
- [Architecture](/vizably/architecture/) — the layers, the folders, and the
contract between the two halves.
- [Account storage](/vizably/account-storage/) — the portable account: on-disk
layout, the fit-check, and the concurrency rules.

:::note[Still to write]
The scan pipeline — Puppeteer, axe-core, and the transformer that turns rule
violations into readable findings — does not have a page here yet. Until it
does, the detail lives in the
[Vizably repository](https://github.com/codrlabs/vizably).
:::
- [URL normalization](/vizably/url-normalization/) — accepting a bare domain
on the landing page without weakening the backend's validation.
- [Scanning](/vizably/scanning/) — Puppeteer, axe-core, and the transform
that turns rule violations into readable findings.
118 changes: 118 additions & 0 deletions src/content/docs/vizably/scanning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
title: Scanning
description: How a submitted URL becomes a categorized WCAG report — Puppeteer, axe-core, and the transform between them.
sidebar:
order: 6
---

Every scan is real: there is no mock mode in the running app. A submitted URL
gets a headless browser, a live axe-core run against the rendered page, and a
pure transform into Vizably's report shape.

## The pipeline

```
POST /api/scan { url }
│
▼
routes/scan.js → controllers/scanController.js
│ ssrfGuard.validate(url) — reject non-http, private/loopback hosts
▼
services/scanRunner.js — ScanRunner.run(url)
│ launch headless Chromium
│ page.goto(url, { waitUntil: 'domcontentloaded' })
│ inject axe-core into the page context
│ page.evaluate(() => axe.run())
▼
services/axeTransformer.js — transform(axeResults)
│ bucket violations into visualAccessibility /
│ structureAndSemantics / multimedia
▼
res.json(ScanResult) → /results?url=... renders it
```

## `ScanRunner` (`backend/services/scanRunner.js`)

`run(url)` does the whole lifecycle: validate, launch, navigate, inject,
evaluate, transform, close. A few decisions worth knowing if you're touching
this file:

- **Waits for DOM ready, not network idle.** Busy sites (ad-heavy pages,
chat widgets) never reach `networkidle0` and would time out waiting for
it. The runner waits for `domcontentloaded`, then gives the page a short,
best-effort idle window (`waitForNetworkIdle`, 500ms idle / 5s cap,
swallowed on timeout) so late content has a chance to settle without
blocking the scan on it.
- **Bypasses CSP before navigating.** Many sites ship a strict
`Content-Security-Policy` that would otherwise block the injected
`<script>` tag axe-core needs.
- **Resolves its browser driver per call, not at module load.** `puppeteer`
ships its own Chromium download; that download doesn't happen in every
deploy target (a serverless build skips it), so the runner probes for a
usable local binary via `puppeteer.executablePath()` and falls back to
`puppeteer-core` + `@sparticuz/chromium` (a Brotli-compressed build
unpacked at runtime) when there isn't one. This is a capability probe, not
an environment-variable branch — `VERCEL` and similar flags are opt-in
settings, not proof a browser is actually available.
- **In Docker, the runner drives system Chromium.** Puppeteer's own download
doesn't run on Alpine's musl libc, so the image installs Chromium via
`apk` and points `PUPPETEER_EXECUTABLE_PATH` at it — see
`backend/Dockerfile`.
- **Constructor deps are injectable**, so tests supply a fake `puppeteer`
and never launch a real browser.

## `axeTransformer` (`backend/services/axeTransformer.js`)

Pure function: `transform(axeResults) → ScanResult`. No I/O, no globals —
same input always produces the same output, which is what makes it testable
without a browser.

`bucketFor(tags)` maps each axe-core violation's `tags` array to one of three
buckets, checked in order:

1. `multimedia` — `cat.text-alternatives`, `cat.media`, `cat.time-and-media`
2. `structureAndSemantics` — `cat.structure`, `cat.semantics`, `cat.tables`,
`cat.parsing`, `cat.aria`, `cat.name-role-value`
3. `visualAccessibility` — everything else (contrast, color, sensory, focus)

Each violation becomes a `{ id, name, category, rootCause, codeSnippet,
solution, count, impact, helpUrl, tags }` entry; `passes` becomes the
`whatsGood` list. `count` is the number of DOM nodes the rule flagged, not
the number of distinct rules.

## `ssrfGuard` (`backend/services/ssrfGuard.js`)

The scan boundary's actual security control. Pure, no I/O, returns
`{ ok: true, url }` or `{ ok: false, reason }` rather than throwing so
callers can map a failure straight to a 4xx response.

Rejects:

- Anything that isn't `http:` or `https:`.
- `localhost` and its aliases.
- Private/loopback IPv4 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`,
`127.0.0.0/8`, `169.254.0.0/16`).
- IPv6 loopback (`::1`) and unique-local/link-local ranges (`fc00::/7`,
`fe80::/10`).

The controller runs it once on the raw request, and `ScanRunner.run` runs it
again before ever touching Puppeteer — the frontend's own check in
[URL normalization](/vizably/url-normalization/) is a convenience layer, not
a boundary; this is the boundary.

## Wiring

`app.js`, the composition root, constructs one `ScanRunner` and injects it
into `ScanController`. Nothing else constructs either directly — see
[Architecture](/vizably/architecture/) for why that matters.

## Tests

| File | Covers |
| --- | --- |
| `backend/tests/scanRunner.test.js` | Orchestration, with a fake Puppeteer — no real Chromium in CI |
| `backend/tests/axeTransformer.test.js` | Bucketing and shape of `transform()` |
| `backend/tests/ssrfGuard.test.js` | Every rejection case above |
| `backend/tests/scan.test.js` | `POST /api/scan` end to end, via `buildApp({ scanRunner: fake })` |

Run with `npm test` in `backend/`.
Loading