diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..797053a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Keeps the actions pinned in .github/workflows current. They are pinned to +# full commit SHAs, with the version in a comment, so a moved tag cannot +# change what runs; Dependabot updates the SHA and the comment together. +# +# npm dependencies are not listed here: no entry existed before, and adding +# one is a separate decision about how many pull requests this repo wants. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # A release has a week to be pulled or found bad before a pin moves to it. + cooldown: + default-days: 7 + groups: + actions: + patterns: ["*"] + commit-message: + prefix: ci diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f63f453..0f0b301 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,8 +5,9 @@ on: push: branches: [main, staging] -permissions: - contents: read +# Nothing at the workflow level: each job asks for what it uses, and no job +# here pushes, tags or comments, so each reads the repository and no more. +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,11 +16,17 @@ concurrency: jobs: check: name: astro check + tests + build + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here pushes, so the token is not left in .git/config for + # every later step (and every npm install script) to read. + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: "npm" @@ -30,20 +37,26 @@ jobs: - name: Type-check run: npm run check - # `npm test` runs vitest, builds the site, and then verifies that every - # inline script in the build is covered by the CSP hash allowlist in - # vercel.json — so an edit to the theme bootstrap cannot ship a page the - # browser will refuse to run. + # `npm test` builds the site, runs the tests (vp test), and then verifies + # that every inline script in the build is covered by the CSP hash + # allowlist in vercel.json — so an edit to an inline script cannot ship + # a page the browser will refuse to run. - name: Tests, build, and CSP check run: npm test audit: name: npm audit + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here pushes, so the token is not left in .git/config for + # every later step (and every npm install script) to read. + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: "npm" @@ -62,16 +75,11 @@ jobs: - name: Audit what ships run: node scripts/audit.mjs - # Build and test tooling, reported but not blocking. - # - # vitest currently carries an advisory with no installable fix: every - # published version up to 4.1.10 is affected, and 4.1.11 — the fix — - # cannot be installed, because it trips a resolver bug in npm 10.9 - # ("Cannot read properties of null (reading 'edgesOut')") that aborts - # `npm install` outright. Blocking on it would pin this job red until - # npm ships a fix, which trains everyone to ignore it. So it is surfaced - # in the log instead, and this step goes back to blocking the moment - # vitest 4.1.11 installs cleanly. + # Build and test tooling, reported but not blocking. None of it ships: + # the site is static HTML. Blocking on an advisory in a build tool with + # no non-breaking fix would pin this job red and train everyone to + # ignore it, so it is surfaced in the log instead. Today that is + # postcss-selector-parser, through @tailwindcss/typography. - name: Report advisories in build and test tooling continue-on-error: true run: npm audit --audit-level=moderate diff --git a/.github/workflows/main-version.yml b/.github/workflows/main-version.yml index 68e591a..c255f94 100644 --- a/.github/workflows/main-version.yml +++ b/.github/workflows/main-version.yml @@ -14,8 +14,9 @@ on: options: [minor, major] default: minor -permissions: - contents: read +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} jobs: version: diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml index 76bfb08..f6e5e70 100644 --- a/.github/workflows/staging-version.yml +++ b/.github/workflows/staging-version.yml @@ -13,8 +13,9 @@ on: options: [patch, minor, major] default: patch -permissions: - contents: read +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} jobs: version: diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c58af..58626e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,54 @@ numbers below were the site's earlier, hand-kept numbering and are retired; the from 0.8.2 stays, as history: flags it hid stay concealed, and versions before 0.9.0 used the storage key `gbx-hide-flags` and asked the licence host `licences.nyuchi.dev`. +- **Google Analytics loads only after consent (#83).** Google's tag is no + longer on the page behind Consent Mode set to denied, which still sent + Google a cookieless ping; it is not requested at all until the visitor + agrees. `Analytics.astro` queues the denied defaults and holds the only + loader, which runs on a stored yes from the current banner version or when + `CookieBanner.astro` records one. A record from an older version, the first + banner's `analytics-consent` key, or anything malformed counts as no answer, + and the banner asks again; `SupportWidget.astro` applies the same check + through `parseConsent` in `src/data/consent.ts`. The privacy and cookie + policies now say that Google Analytics contacts Google only after you agree + (Google Fonts is still named separately), dated 13 October 2026, as is + llms.txt. The CSP has one inline-script hash fewer. `tests/consent.test.ts` + runs the built script against current, old, legacy and malformed records, + and `npm run consent:probe` now fails on any Google Analytics request + before consent. +- **CI asks for less (#84).** Every workflow grants nothing at the top level; + each job asks for its own permissions (`contents: read` for the build and + audit jobs, `contents: write` only for the two tagging jobs). The build + workflow's checkouts set `persist-credentials: false`, since nothing there + pushes. `actions/checkout` and `actions/setup-node` are pinned to full + commit SHAs (v4.4.0 each), with the version in a comment, and the new + `.github/dependabot.yml` keeps those pins current, a week after each + release (`cooldown`). The reusable workflows + from `nyuchi/.github` were already pinned to a commit. +- **Docs match the site as it is (#86).** README, CONTRIBUTING and + `docs/toolchain.md` describe the Astro site's pages, the staging → main + release flow, Vite+ as adopted (no longer "cannot be adopted"), the inline + scripts the CSP hashes, and the current audit; the dead Lint badge, the + vitest pin, `master` and "two pages" are gone. `react` and `react-dom` are + no longer dev dependencies: nothing imports them, and `@bundu/ui` declares + no peer dependencies. Prettier and `vp fmt` both stay, because each is run + by a required org gate; `tests/toolchain.test.ts` fails if their settings + differ. + +### Fixed + +- **Dependency advisories.** `http-cache-semantics` takes its patched + release (GHSA-ch52-4w7c-c8xp), so its dated exception in + `scripts/audit.mjs` is gone. `postcss-selector-parser` is held at 7.1.6 or + later through an npm `overrides` entry (GHSA-rj75-hqrm-r3gf): the + typography plugin pins 6.0.10, and the built CSS is byte-for-byte the same + with the newer version. +- **Anchors for the extension's test builds 0.9.6, 0.9.7 and 0.9.8 (#85)** + on the Releases page, under 0.10.0 beside 0.9.1 to 0.9.5, so the links from + those builds' menus land on the release they lead to. 0.10.0 is still + "Coming soon to the Chrome Web Store". `taggedTestBuilds` in + `src/data/releases.ts` lists every tagged test build, and a test fails the + build if one has no anchor, or more than one. ## 0.1.0 — 2026-10-07 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b57b738..9d6a131 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,15 +5,18 @@ three predictable places. ## What gets changed where -| Change | File | -| --------------------------------------------------------- | ----------------------------------------------------- | -| Nav links, footer columns, legal links, the wordmark | `src/data/site.ts` | -| Links to the extension (repo, releases, deployment guide) | `src/data/site.ts` (`toddleExtension`) | -| The three destination cards | the `surfaces` array in `src/pages/index.astro` | -| Home page copy | `src/pages/index.astro` | -| Extension page copy | `src/pages/toddle-enhancement-extension.astro` | -| Header / footer structure | `src/components/SiteHeader.astro`, `SiteFooter.astro` | -| `
`, metadata, theme bootstrap | `src/layouts/BaseLayout.astro` | +| Change | File | +| ------------------------------------------------------- | ----------------------------------------------------- | +| Nav links, footer columns, legal links, the wordmark | `src/data/site.ts` | +| Links to the extension (store listing, releases, guide) | `src/data/site.ts` (`toddleExtension`) | +| The extension's release notes | `src/data/releases.ts` | +| Facts on the legal pages (versions, prices, contacts) | `src/data/legal.ts` | +| Consent categories and the banner's version | `src/data/consent.ts` | +| The three destination cards | the `surfaces` array in `src/pages/index.astro` | +| Home page copy | `src/pages/index.astro` | +| Extension page copy | `src/pages/toddle-enhancement-extension.astro` | +| Header / footer structure | `src/components/SiteHeader.astro`, `SiteFooter.astro` | +| ``, metadata, theme bootstrap | `src/layouts/BaseLayout.astro` | ## What must not be changed here @@ -38,9 +41,11 @@ lowercase spelling anywhere in `src/` fails that test too. npm install npm run dev # http://localhost:4321 — hot reload npm run check # astro check -npm test # vitest, then build, then the CSP hash check +npm test # build, then the tests (vp test), then the CSP hash check +npm run lint # vp lint npm run format # prettier --write npm run format:check # the org lint gate runs this +npm run fmt # vp fmt; the org vite-plus gate runs `vp fmt --check` ``` ## Keeping the clone light @@ -62,11 +67,12 @@ clones (`toddle-enhancement-extension`, `learning`, `nyuchi-licence-server`) the pull request belongs to. The same script is in all three; only its list of generated paths differs. -## If you touch the theme bootstrap +## If you touch an inline script -The one inline script in `BaseLayout.astro` is allowed by a hash in the -Content-Security-Policy in `vercel.json`. Change the script and the hash no -longer matches, so: +The inline scripts (the theme bootstrap in `BaseLayout.astro`, the consent +check in `Analytics.astro`, and the small modules Astro inlines) are allowed by +hashes in the Content-Security-Policy in `vercel.json`. Change one and its hash +no longer matches, so: ```sh npm run build @@ -75,8 +81,12 @@ npm run csp:fix # rewrite vercel.json with the new hashes Then review the diff — a changed hash should correspond to a change you meant to make. `npm test` fails if you skip this, which is the point: a stale hash -would mean the browser refuses to run the script and the theme silently breaks -in production. +would mean the browser refuses to run the script and it silently breaks in +production. + +If you touch the consent banner or analytics, run `npm run consent:probe` too: +Google's tag must not be requested before the visitor agrees, and the privacy +policy says so. ## Adding a page @@ -87,9 +97,14 @@ in production. ## Pull requests -CI runs `astro check`, the tests, the build and the CSP check, plus the org -lint gate (actionlint, JSON validity, Prettier, markdownlint, yamllint). All of -it has to be green. Merging to `master` deploys to Vercel. +Branch from `staging` and open the pull request against `staging`. CI runs +`astro check`, the build, the tests and the CSP check, plus the org gates: the +lint gate (actionlint, JSON validity, Prettier, markdownlint, yamllint) and +`vite-plus / check` (`astro check`, `vp fmt --check`). All of it has to be +green. Each merge into `staging` is tagged as the next patch. A release is a +pull request from `staging` to `main`: production deploys from `main`, which +is tagged as the next minor. Add a line under "Unreleased" in CHANGELOG.md +for anything a reader of the site, or of this repo, would notice. Do not add a link to the extension's source repository — it is private, and a test fails the build on any `github.com` link for that reason. diff --git a/README.md b/README.md index 7b69a4c..d96c6f7 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,25 @@ > surface, and the home of the classroom tools Nyuchi Web Services builds for > schools. -[](https://github.com/nyuchi/learning/actions/workflows/lint.yml) [](https://github.com/nyuchi/learning/actions/workflows/build.yml)   -**Version:** the latest git tag (see [CHANGELOG.md](CHANGELOG.md)) | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Default branch:** `main` | **Deploy:** Vercel +**Version:** the latest git tag (see [CHANGELOG.md](CHANGELOG.md)) | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Work lands on:** `staging` | **Production:** `main` | **Deploy:** Vercel --- ## What it is -A small static Astro site with two pages: +A small static Astro site: -| Route | What it does | -| ------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `/` | Points visitors at the three surfaces the learning content lives on, and at the tool they can use today. | -| `/toddle-enhancement-extension` | The product page for the Toddle Enhancement Extension. | +| Route | What it does | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `/` | Points visitors at the three surfaces the learning content lives on, and at the tool they can use today. | +| `/toddle-enhancement-extension` | The product page for the Toddle Enhancement Extension. | +| `/toddle-enhancement-extension/releases` | The extension's release notes, for teachers, from `src/data/releases.ts`. | +| `/feedback`, `/feedback/thanks` | The feedback form, posted to Formspree. | +| `/legal/*`, `/accessibility` | Privacy, cookies, terms, data handling, student privacy, security and the rest, from `src/data/legal.ts`. | The redirect job has not gone away — the content split described in [CHANGELOG.md](CHANGELOG.md) still holds — but this domain is now also where @@ -54,8 +56,14 @@ every Nyuchi surface at once. ## Hosting -Vercel, from `main`. The site is fully static — every page is known at build -time, so nothing renders per request. +Vercel. The site is fully static — every page is known at build time, so +nothing renders per request. + +Work goes to `staging` by pull request, and each merge there is tagged as the +next patch (`.github/workflows/staging-version.yml`). A release is a pull +request from `staging` to `main`; production deploys from `main`, which is +tagged as the next minor with a GitHub release +(`.github/workflows/main-version.yml`). See [CHANGELOG.md](CHANGELOG.md). Response headers, including the CSP, are declared in `vercel.json`. @@ -63,54 +71,58 @@ Response headers, including the CSP, are declared in `vercel.json`. ```sh npm install -npm run dev # http://localhost:4321 — hot reload -npm run check # astro check (TypeScript across .astro files) -npm test # build, then vitest, then the CSP hash check -npm run qa # drive a real browser: accessibility + layout, 18 combinations -npm run format # prettier --write -npm run build # produces .vercel/output for the Vercel adapter -npm run preview # serve the built output locally +npm run dev # http://localhost:4321 — hot reload +npm run check # astro check (TypeScript across .astro files) +npm test # build, then the tests (vp test), then the CSP hash check +npm run lint # vp lint (oxlint, type-aware) +npm run format # prettier --write; format:check is what CI runs +npm run fmt # vp fmt; `vp fmt --check` is the org's vite-plus gate +npm run qa # drive a real browser: accessibility + layout, 18 combinations +npm run consent:probe # drive a real browser through the cookie banner +npm run csp:probe # check the Intercom messenger against the CSP in a browser +npm run build # produces .vercel/output for the Vercel adapter +npm run clean # remove build output, caches and finished branches ``` ## Security headers `vercel.json` carries a real Content-Security-Policy, not just the framing and -sniffing headers. `script-src` is `'self'` plus a hash for each inline script, -so nothing inline runs unless it was in the build that was reviewed. +sniffing headers. `script-src` is `'self'`, the hosts of the two third parties +(Google's analytics tag, after consent, and the Intercom messenger, when asked +for), and a hash for each inline script, so nothing inline runs unless it was +in the build that was reviewed. -There is exactly one inline script — the theme bootstrap, which has to run -before the first paint or the page flashes light before going dark. A hash -allowlist is only worth something if it cannot silently fall out of date, so -`npm test` verifies it: +The inline scripts are the theme bootstrap, which has to run before the first +paint or the page flashes light before going dark; the analytics consent check +in `Analytics.astro`, which loads Google's tag only after the visitor agrees; +and the small modules Astro inlines. A hash allowlist is only worth something +if it cannot silently fall out of date, so `npm test` verifies it: ```sh npm run csp # fail if a built inline script is not in the CSP npm run csp:fix # rewrite vercel.json with the current hashes, then review ``` -Edit the bootstrap without running `csp:fix` and CI fails with the missing -hash, rather than the theme quietly breaking in production. +Edit an inline script without running `csp:fix` and CI fails with the missing +hash, rather than the script quietly breaking in production. ## Notes on the toolchain -Vite+ was evaluated and cannot be adopted yet — npm cannot install it, and both -CI and Vercel install with npm. The evaluation, and the conditions for -revisiting it, are in [docs/toolchain.md](docs/toolchain.md). +npm, Astro 7, Tailwind 4, Vite+ (`vp test`, `vp lint`, `vp fmt`), Prettier. +More in [docs/toolchain.md](docs/toolchain.md). -- **Astro 7 with Tailwind 4.** `@bundu/ui@0.1.1` ships a Tailwind 3 style - config, which Tailwind 4 consumes through `@config` — that is what makes - `text-h2`, `ease-soft`, `max-w-narrow` and the mineral colours resolve, both - as utilities here and inside the kit's own `@apply` rules. Verified in the - build output: every component class, the full token set, and the `dark:` - variants are all present. +- **Astro 7 with Tailwind 4.** `@bundu/ui` ships a Tailwind 3 style preset, + which Tailwind 4 consumes through `@config` — that is what makes `text-h2`, + `ease-soft`, `max-w-narrow` and the mineral colours resolve, both as + utilities here and inside the kit's own `@apply` rules. - **`overrides` pins `path-to-regexp` to `^6.3.0`.** `@astrojs/vercel` pulls in a version with a high-severity advisory and there is no fixed release upstream; the override is within the same major, so it is a patch rather than a behaviour change. -- **`vitest` is pinned to `~4.0.18`.** `4.1.11` trips a resolver bug in npm - 10.9 (`Cannot read properties of null (reading 'edgesOut')`) and - `npm install` cannot complete at all. This is why the advisory on vitest is - reported rather than blocking in CI — see below. +- **Two formatters, one set of settings.** The org runs two required gates: + a lint gate that runs Prettier, and `vite-plus / check`, which runs + `vp fmt --check`. `.prettierrc` and the `fmt` block in `vite.config.ts` hold + the same settings, and `tests/toolchain.test.ts` fails if they drift. - **`*.astro` is in `.prettierignore`.** The org lint gate runs Prettier from `nyuchi/.github`, which has no `prettier-plugin-astro`, so it cannot parse those files. They are still type-checked by `npm run check`. @@ -136,28 +148,34 @@ CI splits the audit in two: | Audit what ships | `--omit=dev`, high and above | yes — expected to stay at zero | | Report advisories in build and test tooling | everything, moderate and above | no | -The split exists because of one specific, temporary situation: every published -vitest up to `4.1.10` carries an advisory, and `4.1.11` — the fix — cannot be -installed under npm 10.9. Blocking on it would pin the job red indefinitely, -which teaches people to ignore a red audit. Surfacing it in the log keeps it -visible without that cost. The second step goes back to blocking as soon as -vitest `4.1.11` installs cleanly. +The blocking step is `scripts/audit.mjs`: `npm audit --audit-level=high +--omit=dev`, with a dated list of advisories set aside by ID, each with the +reason it cannot reach a visitor and a date to recheck. Past that date the step +fails again. The second step keeps advisories in the tooling visible in the log +without pinning the job red over something that never ships. ## Repo layout ``` src/ - data/site.ts nav, footer, wordmark, extension links + data/site.ts nav, footer, wordmark, extension links, analytics id + data/legal.ts the facts the legal pages are built from + data/consent.ts consent categories, storage key, version + data/releases.ts the extension's release notes layouts/BaseLayout.astro head, theme bootstrap, header + footer - components/ Wordmark, SiteHeader, SiteFooter, MineralStrip - pages/index.astro the home page - pages/toddle-enhancement-extension.astro + components/ Wordmark, SiteHeader, SiteFooter, Analytics, + CookieBanner, SupportWidget + pages/ home, the extension and its releases, + feedback, accessibility, legal/* styles/global.css @bundu/ui imports and nothing else -scripts/check-csp.mjs CSP hash verification -tests/ links, branding, security +public/llms.txt the site, summarised for language models +scripts/ CSP check and fix, audit, browser QA and + probes, clean +tests/ links, branding, security, consent, SEO, + toolchain +docs/toolchain.md how the tools fit together ``` ## Licence -See [LICENSE](LICENSE) if present; otherwise all rights reserved by Nyuchi -Africa. +Proprietary. See [LICENSE](LICENSE). diff --git a/docs/toolchain.md b/docs/toolchain.md index 9e46aaf..0148765 100644 --- a/docs/toolchain.md +++ b/docs/toolchain.md @@ -1,71 +1,52 @@ # Toolchain -npm, Astro 7, Tailwind 4, Vitest, Prettier. Deploys to Vercel. - -## Why not Vite+ - -[Vite+](https://viteplus.dev) was evaluated on 2026-09-28 and **cannot be -adopted yet**. Not for lack of merit — it works well — but because it cannot -be installed by npm, and both CI and Vercel install with npm. - -### What was tried - -`vp migrate --no-interactive` on a copy of this repo, on Vite+ 1.0.0: - -| Step | Result | -| -------------------------------- | ----------------------------------------------------------- | -| `vp migrate` | clean; rewrote 5 files' imports, 2 configs, added git hooks | -| `vp install` | clean, **0 vulnerabilities** | -| `vp test run` | all 41 tests pass | -| `vp run build` | builds the site correctly | -| Oxfmt vs the org Prettier config | near-identical output; only `README.md` differed | - -So the tool itself is fine. The problem is upstream of it. - -### The blocker - -``` -$ npm install vite-plus@1.0.0 -npm error Cannot read properties of null (reading 'edgesOut') -``` - -npm 10.9.7 crashes resolving `vite-plus`'s dependency graph. This is the same -resolver bug that stops `vitest@4.1.11` installing here, which is why this repo -pins `vitest` to `~4.0.18`. - -Installing through Vite+'s own installer works, but produces a lockfile that -`npm ci` then rejects: - -``` -$ npm ci -npm error `npm ci` can only install packages when your package.json and -npm error package-lock.json are in sync. -npm error Missing: lightningcss-android-arm64@1.33.0 from lock file -``` - -The optional platform binaries are resolved differently by the two installers. - -### Why that is decisive - -**Vercel builds this site with npm.** If npm cannot install the tree, the site -stops deploying. Adopting Vite+ therefore means changing the install command in -CI _and_ in the Vercel project settings to run Vite+'s installer first — a -`curl … | bash` step in front of every build, and a change in a dashboard -outside this repo. - -The org lint gate is a second problem: it is a reusable workflow in -`nyuchi/.github` that this repo does not control, and `vp migrate` deletes -`.prettierrc` in favour of Oxfmt. That file has to stay for the gate to pass. - -### When to revisit - -- npm ships a fix for the `edgesOut` resolver crash — then `npm install -vite-plus` works and most of this goes away. Worth retesting on any npm - newer than 10.9.7; it could not be tested here. -- Or Vite+ publishes a lockfile that `npm ci` accepts. -- Or the estate moves off `npm ci` deliberately, for reasons bigger than this - repo. - -If you adopt it anyway, the safe shape is: Vite+ for `test`, `build` and `dev`; -**keep Prettier and `.prettierrc`** so the org gate still passes; and change -Vercel's install command in the same sitting, not afterwards. +npm, Astro 7, Tailwind 4, Vite+ and Prettier. Deploys to Vercel. + +## What runs what + +| Command | Tool | Where it runs | +| ----------------------- | ---------------------------------- | ---------------------------------- | +| `npm run check` | `astro check` | `build` workflow, `vite-plus` gate | +| `npm test` | `astro build`, `vp test run`, CSP | `build` workflow | +| `npm run lint` | `vp lint` (oxlint, type-aware) | locally | +| `npm run format:check` | Prettier with `.prettierrc` | the org lint gate | +| `npm run ci:check` | `astro check` and `vp fmt --check` | the org `vite-plus / check` gate | +| `npm run qa` | Playwright and axe-core, Chromium | locally, before a visual change | +| `npm run consent:probe` | Playwright, Chromium | locally, before a consent change | + +The `build` workflow is this repo's own (`.github/workflows/build.yml`). The +lint gate and the `vite-plus` gate are reusable workflows in `nyuchi/.github` +that this repo does not control. + +## Vite+ + +`vite-plus` 1.0 is a dev dependency and npm installs it; CI and Vercel both +install with `npm ci`. It replaced `vitest` (tests import from +`vite-plus/test`), and `vite.config.ts` carries the test settings, type-aware +linting and the org's format settings. Astro does not read `vite.config.ts`; +it builds with its own Vite config from `astro.config.mjs`, so nothing there +can change the site. + +`vp check` would run lint, format and type checks in one, but the +`vite-plus / check` gate runs the transitional `ci:check` instead, because +`vp check` reports one type error in `astro.config.mjs` (a `Date` assigned to +the sitemap's string `lastmod`). That is site config, left for its own change. + +## Two formatters + +Prettier and Vite+'s formatter (Oxfmt, through `vp fmt`) both format this +repo, because both org gates are required: the lint gate runs Prettier, and +the `vite-plus` gate runs `vp fmt --check`. Dropping either would fail a gate +this repo cannot change, so both stay, with one set of settings: + +- `.prettierrc` mirrors the org's Prettier config, and the lint gate needs it; +- the `fmt` block in `vite.config.ts` mirrors `nyuchi/.github`'s + `.oxfmtrc.json` (`vp fmt` reads only that block); +- `tests/toolchain.test.ts` fails if the two differ. + +Edit one, edit the other in the same change. If the org retires either gate, +remove that formatter's config and the test with it. + +`*.astro` stays in `.prettierignore`: the org lint gate runs Prettier without +`prettier-plugin-astro`, so it cannot parse those files. `npm run check` +type-checks them. diff --git a/package-lock.json b/package-lock.json index fff65c8..013476a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,6 @@ "axe-core": "^4.12.1", "playwright": "^1.63.0", "prettier": "3.3.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite-plus": "^1.0.0" @@ -5669,9 +5667,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/https-proxy-agent": { @@ -6847,9 +6845,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6959,29 +6957,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7143,13 +7118,6 @@ "node": ">=11.0.0" } }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index edd81b0..86f767a 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,13 @@ "axe-core": "^4.12.1", "playwright": "^1.63.0", "prettier": "3.3.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite-plus": "^1.0.0" }, "overrides": { - "path-to-regexp": "^6.3.0" + "path-to-regexp": "^6.3.0", + "postcss-selector-parser": "^7.1.6" }, "packageManager": "npm@11.12.1" } diff --git a/public/llms.txt b/public/llms.txt index d70c1d8..f103ec9 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -135,15 +135,15 @@ community-based platforms. Nyuchi Web Services is its development division. ## Legal -All dated 7 October 2026. Operated by Nyuchi Web Services, the development +All dated 13 October 2026. Operated by Nyuchi Web Services, the development division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. - [Privacy policy](https://learning.nyuchi.com/legal/privacy): what the extension and the site do with data. In short, nothing the extension reads leaves the browser; it sends only feedback a person chooses to send and, while a licence is entered, a data-free daily check for cancelled keys; this website - uses Google Analytics only with consent, and sets no cookies unless the - visitor agrees. Nyuchi Africa (Private) Limited is the controller for the + loads Google Analytics only after the visitor agrees to it, and sets no + cookies unless the visitor agrees. Nyuchi Africa (Private) Limited is the controller for the data it holds (purchases, feedback, support, site analytics), under Zimbabwe's Cyber and Data Protection Act [Chapter 12:07]; requests to privacy@nyuchi.com. diff --git a/scripts/audit.mjs b/scripts/audit.mjs index f8a7907..6fdab7f 100644 --- a/scripts/audit.mjs +++ b/scripts/audit.mjs @@ -18,21 +18,7 @@ const LEVEL = "high"; /* Every entry: the advisory, why it is safe here, when it was set aside and when it must be looked at again. Remove an entry as soon as a fix installs. */ -const IGNORED = [ - { - id: "GHSA-ch52-4w7c-c8xp", - package: "http-cache-semantics", - added: "2026-10-03", - recheck: "2026-11-03", - why: - "http-cache-semantics <=4.2.0 (no patched release) can serve another " + - "user's cached response through max-stale handling. astro 7.3.5 uses " + - "it only in assets/build/remote.js, which caches remote images during " + - "`astro build` (core/build/static-build -> generate). This site is " + - 'output: "static": no server code ships, no shared cache answers ' + - "users, so the vulnerable path never runs outside our own build.", - }, -]; +const IGNORED = []; let raw; try { diff --git a/scripts/probe-consent.mjs b/scripts/probe-consent.mjs index 533c2e0..2c635f2 100644 --- a/scripts/probe-consent.mjs +++ b/scripts/probe-consent.mjs @@ -9,9 +9,11 @@ * reason. Nothing about the page would look broken; the cookie would simply * start appearing, and the policy would quietly become false. * - * So this drives it: load the page, assert no analytics cookie; decline, assert - * still none and the banner is gone; reload, assert it stays gone and still no - * cookie; then accept in a fresh context and assert the cookie appears. + * So this drives it: load the page, assert no analytics cookie and no request to + * Google Analytics at all; decline, assert still neither and the banner is gone; + * reload, assert it stays gone and still nothing; then accept in a fresh context + * and assert Google's tag is requested and the cookie appears. A record from an + * older banner version is planted too, and must load nothing. * * npm run consent:probe */ @@ -33,6 +35,21 @@ const browser = await chromium.launch({ executablePath: findChromium() }); const failures = []; const notes = []; +/* Google Analytics' own hosts. Google Fonts is a different host and is not + covered by the analytics choice, so it is not counted here. */ +const ANALYTICS_HOST = + /(^|\.)(googletagmanager|google-analytics)\.com$|(^|\.)analytics\.google\.com$/; + +/** Every request this page makes to Google Analytics, recorded as it happens. */ +function watchAnalytics(page) { + const seen = []; + page.on("request", (request) => { + const host = new URL(request.url()).host; + if (ANALYTICS_HOST.test(host)) seen.push(request.url()); + }); + return seen; +} + async function analyticsCookies(context) { return (await context.cookies()) .map((cookie) => cookie.name) @@ -51,10 +68,16 @@ function check(label, condition, detail = "") { { const context = await browser.newContext(); const page = await context.newPage(); + const requests = watchAnalytics(page); await page.goto(PAGE, { waitUntil: "networkidle" }); const banner = page.locator(".consent"); check("banner is shown on a first visit", await banner.isVisible()); + check( + "no request to Google Analytics before a choice", + requests.length === 0, + requests.join(", "), + ); /* Equal prominence is a requirement, not a preference: a reject button that is smaller or quieter than accept is the pattern regulators have been fining. @@ -138,6 +161,11 @@ function check(label, condition, detail = "") { cookies.length === 0, cookies.join(", "), ); + check( + "no request to Google Analytics after rejecting, across a reload", + requests.length === 0, + requests.join(", "), + ); /* Withdrawal must be as easy as giving it. */ const reopen = page.locator("#cookie-choices"); @@ -190,11 +218,38 @@ function check(label, condition, detail = "") { await context.close(); } +/* --- a yes given to an older banner version --- */ +{ + const context = await browser.newContext(); + await context.addInitScript(() => { + localStorage.setItem( + "nyuchi-consent", + JSON.stringify({ v: 0, at: "2026-01-01T00:00:00.000Z", analytics: true }), + ); + localStorage.setItem("analytics-consent", "granted"); + }); + const page = await context.newPage(); + const requests = watchAnalytics(page); + await page.goto(PAGE, { waitUntil: "networkidle" }); + await page.waitForTimeout(1000); + check( + "an older version's yes, or the first banner's key, loads nothing", + requests.length === 0, + requests.join(", "), + ); + check( + "and the banner asks again", + await page.locator(".consent").isVisible(), + ); + await context.close(); +} + /* --- accepting, in a clean profile --- */ let acceptPath = "verified"; { const context = await browser.newContext(); const page = await context.newPage(); + const requests = watchAnalytics(page); await page.goto(PAGE, { waitUntil: "networkidle" }); await page.locator('[data-consent-action="accept"]').click(); @@ -211,6 +266,14 @@ let acceptPath = "verified"; ); const cookies = await analyticsCookies(context); + /* The request is made whether or not this browser can complete it, so this + holds even where the bundle itself cannot arrive. */ + check( + "accepting requests Google's tag", + requests.some((url) => url.includes("googletagmanager.com/gtag/js")), + requests.join(", ") || "no request", + ); + if (cookies.length > 0) { notes.push("ok accepting does set the analytics cookie"); notes.push(` cookies after accept: ${cookies.join(", ")}`); @@ -251,8 +314,9 @@ if (acceptPath === "unverifiable") { "measurement. Google's tag never loaded in this browser", "(ERR_CERT_AUTHORITY_INVALID — this sandbox does not trust the local TLS", "proxy, and it fails Google Fonts the same way), so there was no GA to set", - "a cookie either way. The promise the privacy policy makes — no cookie", - "before consent — is proven above and does not depend on it.", + "a cookie either way. The promise the privacy policy makes — no request", + "to Google Analytics and no cookie before consent — is proven above and", + "does not depend on it.", "", "Re-run against the deployed site to close that gap:", " QA_BASE_URL=https://learning.nyuchi.com node scripts/probe-consent.mjs", diff --git a/scripts/qa.mjs b/scripts/qa.mjs index 0d1d273..3782b5d 100644 --- a/scripts/qa.mjs +++ b/scripts/qa.mjs @@ -67,8 +67,10 @@ for (const scheme of ["light", "dark"]) { * and Google Analytics are there because a commit put them there; anything * else arriving is the regression this exists to catch. * - * Analytics loading eagerly is not a licence for the messenger to, which - * is why these are two assertions rather than one "no third parties". */ + * Google Analytics loads only after consent, which this run never gives; + * scripts/probe-consent.mjs is what asserts that. Either way, analytics + * is not a licence for the messenger to load, which is why these are two + * assertions rather than one "no third parties". */ const APPROVED = /(^|\.)(googleapis|gstatic|googletagmanager|google-analytics)\.com$|(^|\.)analytics\.google\.com$/; const unapproved = []; diff --git a/src/components/Analytics.astro b/src/components/Analytics.astro index d9a4b2e..5a91f10 100644 --- a/src/components/Analytics.astro +++ b/src/components/Analytics.astro @@ -1,30 +1,28 @@ --- /** - * Google Analytics 4, behind Google Consent Mode v2. + * Google Analytics 4 — not loaded at all until the visitor agrees to it. * - * The order of the three scripts below is the whole design, and it is fragile in - * one direction only: the consent defaults must be set BEFORE gtag/js loads. Set - * them after and there is a window in which GA has already written a cookie, the - * banner is still asking, and the privacy policy is wrong. That is why this is - * one `is:inline` block emitted ahead of the loader rather than something tidier, - * and why tests/security.test.ts asserts the ordering. + * What a visitor gets before they choose is nothing from Google Analytics: no + * request to Google, no cookie, no tag. This page defines a local `gtag()` queue + * and a loader function; the loader is what fetches Google's tag, and it runs + * only when analytics consent exists. Two things call it: * - * What a visitor gets before they choose: + * - this script, on page load, when a stored consent record says yes AND was + * written against the current banner (CONSENT_VERSION). A record from an + * older banner, the first banner's LEGACY_CONSENT_KEY, or anything that + * does not parse counts as no answer — which means no Google; + * - CookieBanner.astro, the moment someone agrees. * - * analytics_storage: denied → no GA cookie, nothing kept on the device - * ad_storage, ad_user_data, ad_personalization: denied + * This used to load the tag on every page behind Consent Mode v2 set to denied. + * That kept the "no cookies until you agree" promise but not the stronger one: a + * denied tag still sends Google a cookieless ping. The privacy policy now makes + * the stronger promise, and tests/security.test.ts and scripts/probe-consent.mjs + * hold it to it. * - * Consent Mode v2 requires the three ad_* signals to be declared even though - * this site runs no advertising, so they are declared and denied permanently — - * the banner never grants them, because there is nothing here to grant them for. - * Only `analytics_storage` is ever flipped, by CookieBanner.astro. - * - * An honest caveat, because a data protection lead will find it: with consent - * denied, GA still loads and still sends Google a cookieless ping. That is how - * Consent Mode is designed to work, and it means "no cookies until you agree" is - * exactly true while "no contact with Google until you agree" is not. If you need - * the stronger claim, do not render this component until consent exists; you lose - * Consent Mode's modelling and gain a cleaner sentence. + * Consent Mode is still declared, and still before the loader can run: the + * defaults deny everything, and only `analytics_storage` is ever granted. The + * three ad_* signals are declared because Consent Mode v2 requires them and are + * denied permanently — this site runs no advertising. * * Nothing here is in the extension, which sends nothing it reads anywhere * (its only outside connections are feedback a teacher chooses to send and a @@ -32,57 +30,59 @@ * the marketing site measuring the marketing site. */ import { analytics } from "../data/site"; -import { CONSENT_KEY, LEGACY_CONSENT_KEY } from "../data/consent"; +import { CONSENT_KEY, CONSENT_VERSION } from "../data/consent"; --- { analytics.measurementId && ( - <> - - - - > + /> ) } diff --git a/src/components/CookieBanner.astro b/src/components/CookieBanner.astro index 5edf295..daa70d6 100644 --- a/src/components/CookieBanner.astro +++ b/src/components/CookieBanner.astro @@ -247,19 +247,21 @@ import { legal } from "../data/legal";