diff --git a/.claude/settings.json b/.claude/settings.json index 6407e7c..8712d0b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,5 +25,19 @@ "mcp__claude_ai_Postiz__integrationList", "mcp__Mzizi__mzizi_get_tokens" ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "mcp__github__merge_pull_request", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/clean.mjs\" --hook", + "timeout": 120 + } + ] + } + ] } } diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..cd0ff5e --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,5 @@ +#!/bin/sh +# After a `git pull` that merges: keep the clone light (scripts/clean.mjs). +# Turned on by `npm ci` (scripts/install-hooks.mjs). It never fails a pull. +command -v node >/dev/null 2>&1 || exit 0 +node scripts/clean.mjs --quiet || true 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 ff2c504..0f0b301 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,10 +3,11 @@ name: build on: pull_request: push: - branches: [master] + 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 new file mode 100644 index 0000000..c255f94 --- /dev/null +++ b/.github/workflows/main-version.yml @@ -0,0 +1,36 @@ +# Versioning policy (nyuchi/.github#80): every release to `main` is the next +# MINOR, tagged on the merged commit, with a GitHub release. Staging merges are +# patches (staging-version.yml); a major is only ever by hand. +name: Main version + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + bump: + description: Override the bump (major is only ever manual). + type: choice + options: [minor, major] + default: minor + +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} + +jobs: + version: + # Only main: a manual run started from another branch tags nothing. + if: github.ref == 'refs/heads/main' + uses: nyuchi/.github/.github/workflows/reusable-auto-tag.yml@924e5b4d0983d31379aedbec02b990793f29c792 # main, 2026-10-04 + with: + # Empty: the version is computed from the tags (package.json's version + # is not the release version). + version-command: "" + channel: main + bump: ${{ inputs.bump || '' }} + create-release: true + secrets: + token: ${{ secrets.RELEASE_BUMP_TOKEN }} + permissions: + contents: write diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml new file mode 100644 index 0000000..f6e5e70 --- /dev/null +++ b/.github/workflows/staging-version.yml @@ -0,0 +1,28 @@ +# Versioning policy (nyuchi/.github#80): every merge into `staging` is +# released as the next PATCH, tagged on the merged commit. +name: Staging version + +on: + push: + branches: [staging] + workflow_dispatch: + inputs: + bump: + description: Override the bump (major is only ever manual). + type: choice + options: [patch, minor, major] + default: patch + +# Nothing at the workflow level: the one job below asks for the write access +# that tagging needs, and only it. +permissions: {} + +jobs: + version: + # Only staging: a manual run started from another branch tags nothing. + if: github.ref == 'refs/heads/staging' + uses: nyuchi/.github/.github/workflows/reusable-staging-release.yml@924e5b4d0983d31379aedbec02b990793f29c792 # main, 2026-10-04 + with: + bump: ${{ inputs.bump || '' }} + permissions: + contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2e3fa..60f3e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,266 @@ # Changelog +From this release the site's versions are its git tags, under the org's +versioning policy: each merge into `staging` is a patch tag (from +v0.0.1, 2026-10-04), each release to `main` the next minor. The 3.x and 2.x +numbers below were the site's earlier, hand-kept numbering and are retired; the +`version` in package.json is not the release version. + ## Unreleased +### Added + +- **Mukoko Lingo has its own page, `/mukoko-lingo`.** Mukoko Lingo is a + partner app developed by Nyuchi Learning, part of Mukoko, Africa's super + app. The page says what it does today, who it is for, what schools get + and how to start, in the extension page's style, with the app's own icon. + Every claim is from Lingo's public repository (mukoko-dev/mukoko-lingo: + README.md, app.json, BRANDING.md and the app's own copy). It sits beside + the Toddle Enhancement Extension wherever the extension appears: a second + product card on the home page, "Mukoko Lingo" in the header (where + "Tools" is now "Toddle extension"), and the footer's "Tools" column, now + "Products". It has a SoftwareApplication node in its JSON-LD, Open Graph + tags, a sitemap entry and an entry under Products in llms.txt. + `mukokoLingo` in `src/data/site.ts` holds its links. + `tests/seo.test.ts` checks all of that. +- **`npm run clean`, and after every merge.** `scripts/clean.mjs` removes + the build output (`dist/`, `.astro/`, `.vercel/output/`), the QA report, + the tools' caches, finished Claude Code worktrees and local branches + already in `main` or `staging`, touching only what Git ignores. It runs + after a `git pull` that merges (`.githooks/post-merge`, turned on by + `npm ci` through the new `prepare` script) and after Claude Code merges a + pull request (`.claude/settings.json`). See CONTRIBUTING.md. + +### Changed + +- **Mukoko Lingo is written for organisations first.** The page, the + homepage card and `llms.txt` now lead with schools, businesses and + government, then single learners. The "For schools" section is "For + schools, businesses and government" (anchor `#organisations`), and the + enquiry button reads "Talk to us about your organisation". Lingo is + described as "Part of Mukoko by Nyuchi", and Swahili is shown as beta + (owner decisions, 2026-10-15). + +- **Mukoko Lingo, described as it is.** The home page card and llms.txt + said Lingo was "free for personal use", "Shona and Ndebele primary" and + taught French and Portuguese. Its repository says it teaches Shona, + Ndebele, English and Chinese, and that starting is free; the copy now + says that, and links to the live app at lingo.mukoko.com rather than + mukoko.com/lingo. The home page's "three surfaces" are now the two + products and two destinations. +- **0.11.0 is on the Chrome Web Store, and is the current version (#85).** + It is 0.10.0 with a security fix to how licence keys are checked. The + Releases page lists it first, marked on the store, from the extension's + CHANGELOG; the test builds tagged since 0.10.0 (0.10.1 to 0.10.5, 0.11.1 + and 0.11.2) open its notes, and 0.12.0, not released, is not listed. + `extensionVersions.current` is 0.11.0; the new `changedIn` (0.9.0) keeps + "versions before 0.9.0" true where the old licence host and flags key are + named. The product page, the Security page (which now describes the + 0.11.0 fix), the Releases description and llms.txt name 0.11.0. The + anchors for 0.9.6 and 0.9.7 the issue asked for were already there; the + test that every tagged test build has one now covers the 0.10.x and + 0.11.x builds too. +- **`webStore`'s type is a named `Url`**, so Prettier 3.3 and 3.9 format + `src/data/site.ts` the same way; the org lint gate (3.9.4) had flagged it. +- **After paying: the checkout note describes today's email, a licence + key.** The claim-link wording (#89) is held back until the licence server + sends claim links, which ships with extension 0.12.0. Until then buyers + still receive the key by email, so the note says that. Restore #89 when + 0.12.0 and the server's claim links go live. +- **0.10.0 is on the Chrome Web Store.** The Releases page shows it as on the store since 13 October 2026, and the test builds 0.9.9 and 0.9.10 open its notes. +- **The Toddle Enhancement Extension 0.9.0 is on the Chrome Web Store**, + published on 7 October 2026. The Releases page marks it "On the Chrome Web + Store", dated 7 October 2026. `extensionVersions.current` is 0.9.0 and + there is no `next` until another minor is waiting for the store; + `previous` (0.8.2) names the store release before it where updating from + it still matters. Every "from version 0.9.0" is plain present tense, and + the "0.8.2, on the store today" wording is gone from the product page, the + legal pages, llms.txt and SECURITY.md (`flags.before`, the three switches + of 0.8.2, the sidebar's eye button). What still helps a teacher updating + 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 + +Released with the Toddle Enhancement Extension 0.9.0, which is in Chrome Web +Store review; the site describes it as the next version until it is live. + +### Added + +- **A Releases page for the extension**, at + `/toddle-enhancement-extension/releases`: what changed in each version, + in plain words for teachers, newest first. 0.9.0 is "Coming soon to the + Chrome Web Store"; 0.8.2, 0.8.1, 0.8.0, 0.7.x and the early builds follow, + condensed. Versions 0.8.3 to 0.8.13, test builds for schools piloting the + extension, are said in one line: their changes arrive together in 0.9.0. + Each version has the anchor `#v`, which the extension's menu will + link to. No downloads and no GitHub links: the extension's repository is + private, and teachers install from the Chrome Web Store. The notes live in + `src/data/releases.ts`, so adding a release is one edit. Linked from the + extension page, the footer and llms.txt, and in the sitemap. + ### Changed +- **0.9.0's last changes, on the Releases page and the legal pages.** Text + and icons at the right size on Toddle, a flag's text drawn from its + Markdown, the Excusals tab's Time out column, and a licence key that + follows the teacher's own Chrome account (also kept in Chrome's synced + storage, never sent to Nyuchi). Every page that said the key stays on the + device says so; the wording lives once, in `licenceSync` + (`src/data/legal.ts`). The pricing section shows individual licence holders + the upgrade: code BXXF20VA, US$7 off the organisation licence only. +- **One account of 0.9.0 across the site.** The gradebook's toolbar and the + Attendance dashboard's details run in closed shadow roots in 0.9.0 on every + page; the 6 October review's fixes reach schools in 0.9.0; each version + difference says from which version. +- **Second release review.** The 0.9.0 notes no longer say nothing new is + read (the data handling page lists each read, now including the teachers' + roles and photos the staff view shows); the flags summary, the search data + and the student privacy page say from which version flags are never hidden + and asked for each time; the first review's version is fixed at 0.8.2; the + terms and the product page say the cancelled-keys check is at most once a + day; the staging version workflow runs only on `staging`. +- **A release to `main` is tagged as the next minor** (`main-version.yml`, + the org's reusable auto-tag, with a GitHub release). +- **The licence server's address is `licenses.nyuchi.com`.** The privacy + policy, the data and security pages, the vulnerability disclosure scope, + the extension page's allowlist note, llms.txt and SECURITY.md name the new + host for the daily check for cancelled keys from version 0.9.0, and say + that 0.8.2, on the store today, asks `licences.nyuchi.dev`, the same + server, which still answers. The legal pages' date moves to 7 October 2026. +- **@bundu/ui 0.5.0** (was 0.1.1): the kit's updated tokens. The page + background is a cooler, neutral off-white; layout, type, buttons and the + Nyuchi brand colours are unchanged. Every check and test passes as before. +- **The site's code is proprietary**, all rights reserved, with a LICENSE + file saying so (it had none). Not open source. + +- **The terms say who owns the extension.** A new "Who owns the extension" + section: the extension and its code belong to Nyuchi Web Services, part + of Nyuchi Africa (Private) Limited, all rights reserved, and it is not + open source. A licence grants use, not ownership. Its code may not be + copied, modified, reused or redistributed, in whole or in part, in another + product or service without written permission. Reading the code to check + what it does stays welcome, and building a competing product stays + forbidden. Said once, in `ownership` (`src/data/legal.ts`), and repeated on + the extension page and in llms.txt. +- **Student flags, as 0.9.0 ships them:** a concealed flag is a grey smudge + with no colour, nothing on the page brings it back, and a teacher updating + from 0.8.2 has flags concealed. +- **The extension page and legal pages describe 0.9.0, the next Chrome Web + Store release**, and say where 0.8.2, on the store today, differs. Patches + (0.8.3 to 0.8.13) are test releases that do not go to the store, so + `extensionVersions.next` is now 0.9.0, not 0.8.4. + - Student flags: the extension no longer hides Toddle's own flags. A + teacher can conceal them for a shared screen with the free switch, and + "Show flags" in the sidebar shows one student's. The wording is + "conceal" and "flag visibility", said once in `flags` + (`src/data/legal.ts`); a test fails on "hide flags" on the extension + page, the home page and in llms.txt. The sidebar asks Toddle for a + student's flags each time it opens. The value on Toddle's site is + `tee-blur-flags` (`gbx-hide-flags` in 0.8.2). + - The rebuild: the flag switch, the student sidebar and the home page's + additions run in the extension's isolated world, in closed shadow roots + (product page, Security page, llms.txt). The Security page adds the home + page channel's limit. + - The student sidebar: today's timetable as its own part, the student's + email, a class's teachers to email at once, and stepping aside in + Toddle's admin portal. My classes switches on and off at once. + - For schools: the Admin console, Group Policy, Intune and macOS steps, + the organisation key pasted in once by each teacher, and allowing + the licence server (now `licenses.nyuchi.com`, and `licences.nyuchi.dev` + for 0.8.2), summarised on the extension page; the full guide + on request. A link to the help centre's "How to use" collection. + - The data schema (the extension's `docs/data-schema.md`) is described on + the data handling page and the extension page, available on request. + - Data handling: the licence server keeps a SHA-256 of each key, never + the key itself, and the school's email domains for an organisation key. + - The second security review was of 0.8.4, a test release; its fixes are + in 0.9.0. +- **The privacy policy rests on two laws: Zimbabwe's Cyber and Data + Protection Act [Chapter 12:07] and the EU and UK GDPR**, applied to everyone. + A new "The law we follow" section names POTRAZ as Zimbabwe's regulator, says + where data goes and how transfers out of the EU and UK are covered, and the + rights section lists each right, the one-month answer and where to complain + (POTRAZ, the ICO, or an EU authority). Each legal basis carries its GDPR + article. A breach goes to POTRAZ within 24 hours, as Zimbabwe's Act requires, + and to an EU or UK regulator within 72 where the GDPR requires it. Singapore's + PDPA is no longer named. The facts live once, in `dataProtection` + (`src/data/legal.ts`). +- **The legal pages match the extension's code, for 0.8.2 and 0.8.4**, and + were dated 6 October 2026. (Superseded within this release: the pages now + describe 0.9.0, say flags are concealed rather than hidden, and are dated + 7 October 2026; see the entries above.) Checked against the extension's data schema + (`docs/toddle-data-fields.md`) and its code: + - Student flags: from 0.8.4 they are shown as Toddle shows them, and the + free switch hides them everywhere; versions before 0.8.4 hid them by + default. Unless flags are hidden, the sidebar asks Toddle for a + student's flags each time it opens. + - Memory: answers stay in the tab's memory until it is closed or + reloaded, reused for at most 5 minutes (2 for a student's day). Not + "a few minutes, then discarded". + - Storage: every key, named — the last revocation check and the Toddle + account's email in the extension's storage, and the four values on + Toddle's site (`tee-settings`, `gbx-hide-flags`, `tee-course-view`, + `tee-academic-year`). The "style of Toddle's message button" value it + once listed does not exist. + - Every switch, including gradebook tools and the Attendance dashboard's + student details. + - Licence keys may, not must, name who they are for. + - What each feature reads (gradebook, home page, profile page, Attendance + dashboard, sidebar), and the sign-in headers and academic year noted + from Toddle's own requests. + - Exactly what leaves the device, student photos included. + - The Security page covers the adversarial review of 6 October 2026 + (findings 13 to 16) and what code inside Toddle's page cannot promise. - **The audit sets one advisory aside, by ID, until 2026-11-03** (CI only). GHSA-ch52-4w7c-c8xp in `http-cache-semantics` has no patched release, and astro 7.3.5 uses the package only to cache remote images during diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6767e67..29accf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,15 +5,19 @@ 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 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` | +| Mukoko Lingo's links / page copy | `src/data/site.ts` (`mukokoLingo`) / `src/pages/mukoko-lingo.astro` | +| Header / footer structure | `src/components/SiteHeader.astro`, `SiteFooter.astro` | +| ``, metadata, theme bootstrap | `src/layouts/BaseLayout.astro` | ## What must not be changed here @@ -38,16 +42,38 @@ 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` ``` -## If you touch the theme bootstrap +## Keeping the clone light -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: +`npm run clean` (`scripts/clean.mjs`) removes what the build and the tools +generate (`dist/`, `.astro/`, `.vercel/output/`, `qa-report.json`, the tools' caches), Claude Code worktrees with nothing uncommitted, and local +branches that are done: everything on them is already in `origin/main` or +`origin/staging` (a squash merge included), or their remote branch was +deleted after the pull request merged while they held nothing unpushed. It +touches only what Git ignores, never `main`, `staging`, the branch checked +out or one in a worktree, and names anything it leaves. `--dry-run` lists, +`--deep` also removes `node_modules` and npm's cache, `--no-fetch` works +offline. + +It runs on its own after a `git pull` that merges (`.githooks/post-merge`, +turned on by `npm ci`), and after Claude Code merges a pull request +(`.claude/settings.json`, a `PostToolUse` hook) in whichever of the three +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 an inline script + +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 @@ -56,8 +82,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 @@ -68,9 +98,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/LICENSE b/LICENSE new file mode 100644 index 0000000..d783fcc --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) 2026 Nyuchi Web Services. All rights reserved. + +This website, learning.nyuchi.com, its source code, content, designs and +other materials in this repository (the "Software") are the property of +Nyuchi Web Services and are protected by copyright and other intellectual +property laws. The Software is proprietary. It is not open source. + +No licence or right to copy, modify, merge, publish, distribute, +sublicense, sell, deploy, or otherwise reuse the Software, in whole or in +part, is granted without the prior written permission of Nyuchi Web +Services. Contact: support@nyuchi.com. + +Third-party packages it depends on keep their own licences. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. diff --git a/README.md b/README.md index 0b64c8a..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. -[![Lint](https://github.com/nyuchi/learning/actions/workflows/lint.yml/badge.svg)](https://github.com/nyuchi/learning/actions/workflows/lint.yml) [![build](https://github.com/nyuchi/learning/actions/workflows/build.yml/badge.svg)](https://github.com/nyuchi/learning/actions/workflows/build.yml) ![Astro](https://img.shields.io/badge/Astro-7-BC52EE?style=flat-square&logo=astro&logoColor=white) ![Vercel](https://img.shields.io/badge/Vercel-deployed-000000?style=flat-square&logo=vercel&logoColor=white) -**Version:** 3.0.0 | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Default branch:** `master` | **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 `master`. 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/SECURITY.md b/SECURITY.md index 25a4278..455deb0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,8 +3,11 @@ This repository is `learning.nyuchi.com`: a static Astro site, served by Vercel, and the home of the Toddle Enhancement Extension. -The extension's own security model, how it is tested and its independent -adversarial review are published for schools at +The extension's own security model, how it is tested, and its adversarial +reviews of 1 October 2026 (0.8.2) and 6 October 2026 (0.8.4, a test build; +its fixes reached schools in 0.9.0, on the Chrome Web Store from 7 October +2026), with their findings and the limits of code that runs in Toddle's +page, are published for schools at [learning.nyuchi.com/legal/security](https://learning.nyuchi.com/legal/security). The vulnerability disclosure policy, covering this site, the extension and the licence server, is at @@ -26,7 +29,8 @@ In scope: - Code in this repository, and the site it builds. - The Toddle Enhancement Extension, and the licence server at - `licences.nyuchi.dev` (see the disclosure policy above). + `licenses.nyuchi.com` (and its former address, `licences.nyuchi.dev`, + which still answers; see the disclosure policy above). Out of scope: diff --git a/astro.config.mjs b/astro.config.mjs index 950056e..a9b8591 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,6 +6,10 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ site: "https://learning.nyuchi.com", + /* Astro's HTML compression drops the space where a line break sits between + text and an inline tag ("Applies to…"), across the legal pages. + The pages are small; correct words matter more than a few bytes. */ + compressHTML: false, adapter: vercel(), // Fully static: every page is known at build time, so there is nothing to @@ -31,7 +35,8 @@ export default defineConfig({ */ serialize(item) { item.url = item.url.replace(/(.+)\/$/, "$1"); - item.lastmod = new Date(); + // The sitemap item takes an ISO 8601 string, not a Date. + item.lastmod = new Date().toISOString(); return item; }, }), 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 c71d146..013476a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,9 @@ "": { "name": "nyuchi-learning", "version": "3.0.0", + "license": "UNLICENSED", "dependencies": { - "@bundu/ui": "^0.1.1", + "@bundu/ui": "^0.5.0", "astro": "^7.3.5" }, "devDependencies": { @@ -21,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" @@ -666,19 +665,25 @@ "win32" ] }, + "node_modules/@bundu/server": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bundu/server/-/server-0.1.0.tgz", + "integrity": "sha512-tGpZSvuU/hZn6w0sggA/6/8hqLSSCm+ihboU6y+IJ1LYzxIhngOELEm77pdpcx466PAgln4vPpM7GmuGU1EApw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@bundu/ui": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@bundu/ui/-/ui-0.1.1.tgz", - "integrity": "sha512-lyRwZLr4U8nLpdaCfPikjoZ6OUKGyQzdjregFYxFyrUY+98ytRbWc3bZA20626swYnAIYjlJDa8/fYJqh4u4cA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@bundu/ui/-/ui-0.5.0.tgz", + "integrity": "sha512-391EJhoMD0K+nuwJPQGOPDvEpmUJsIjRrxWX9+W0fFqr5oPliQVov/ZpZjOMZO3lxAa7bu5zaIxVO8t9h8gaBA==", "license": "MIT", "dependencies": { + "@bundu/server": "^0.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.0.2" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" } }, "node_modules/@capsizecss/unpack": { @@ -5662,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": { @@ -6840,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": { @@ -6952,27 +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==", - "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==", - "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", @@ -7134,12 +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==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -7313,9 +7291,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 945fa37..86f767a 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,12 @@ "name": "nyuchi-learning", "version": "3.0.0", "private": true, + "license": "UNLICENSED", "type": "module", "description": "learning.nyuchi.com — the Nyuchi Learning surface and the home of the Toddle Enhancement Extension.", "scripts": { + "clean": "node scripts/clean.mjs", + "prepare": "node scripts/install-hooks.mjs", "dev": "astro dev", "build": "astro build", "preview": "astro preview", @@ -23,7 +26,7 @@ "ci:check": "astro check && vp fmt --check" }, "dependencies": { - "@bundu/ui": "^0.1.1", + "@bundu/ui": "^0.5.0", "astro": "^7.3.5" }, "devDependencies": { @@ -36,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 6d86cc1..8254b81 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,8 +1,9 @@ # Nyuchi Learning -> learning.nyuchi.com — the Nyuchi Learning surface of Nyuchi Africa. Two -> things live here: signposts to where the learning content moved, and the -> classroom software Nyuchi Web Services builds for schools. +> learning.nyuchi.com — the Nyuchi Learning surface of Nyuchi Africa. It is +> the home of Nyuchi Learning's two products, the Toddle Enhancement +> Extension and Mukoko Lingo, and of signposts to where the learning content +> moved. Nyuchi Africa is a technology company in Zimbabwe building open-source and community-based platforms. Nyuchi Web Services is its development division. @@ -17,52 +18,138 @@ community-based platforms. Nyuchi Web Services is its development division. score-based rubrics, single-point rubrics, checklists, scores, comments, grade scales, learning goals — into one column per standard, criterion or item, so a teacher sees the whole class at once instead of opening a side - panel per student. It also adds a switch that hides Toddle's student flags - across the product, so a gradebook can be projected or screen-shared without - a flag being visible. Flags are hidden by default; a staff member shows them - deliberately, with the switch or an eye button for one student. - It hides flags, not names. Free: the flag switch and a "My classes" filter on the - Toddle home page. With a licence: the per-criterion gradebook columns, CSV - export, primary teacher names, and the student sidebar. The sidebar (version - 0.8.2) works anywhere a student appears in Toddle (class pages, the - Attendance dashboard's Periods, Students and Excusals tabs, name links, - gradebook student cells) and shows a photo, grade, age and class of, Student - Group, room number, homeroom advisor, contacts with relationship, phone and - email, a "Now" card (current attendance code, block, class, room and - timetabled teacher), today's daily attendance code, and the student's - classes in Today and All tabs. Clicking a class opens a class view with its - current block, primary teacher, parent course, room and every teacher with - their display title and email. Message buttons open Toddle's own chat; the - extension sends no message itself. On the Attendance dashboard's Students + panel per student. It also gives teachers control of flag visibility on a + shared screen. Since version 0.9.0, on the Chrome Web Store from + 7 October 2026, the extension never hides Toddle's own student flags; a teacher can + conceal them with one free switch, in Toddle's header or the extension's + menu, so a gradebook can be projected or screen-shared without what a flag + says being readable from across the room. In the student sidebar, "Show flags" shows + one student's flags. Concealing is a screen, not a lock: a concealed flag + can still show that a student has a flag. A teacher who updated from + 0.8.2, where flags were hidden by default, or who had hidden them, has + them concealed until they choose to show them. + It conceals flags, not names. Free: the flag switch and a "My classes" + filter on the Toddle home page. With a licence (US$5 a year for one + teacher, US$149.99 a year for a school's email domain): the per-criterion + gradebook columns, CSV export, primary teacher names, the Attendance + dashboard's details, and the student sidebar. An individual + licence holder is offered the upgrade to their school's licence, with the + code BXXF20VA, which takes US$7 off the organisation licence only. The sidebar works anywhere a + student appears in Toddle (class pages, the Attendance dashboard's Periods, + Students and Excusals tabs, name links, gradebook student cells), except + in School setup, Toddle's admin portal, where it steps aside. It shows a + photo, grade, age and class of, the student's email (click to copy), + Student Group, room number, homeroom advisor, contacts with relationship, + phone and email, a "Now" card (current attendance code, block, class, room + and timetabled teacher), and today's timetable as its own part, which + shows today's periods, "No classes today", or "Couldn't load timetable" + with a Retry button, without holding up the rest of the panel. Clicking a + class opens a class view with its current block, primary teacher, parent + course, room and every teacher with their display title and email; the + teachers can be emailed together. Message buttons open Toddle's own chat; + the extension sends no message itself. The flag switch, + the student sidebar, the home page's additions, the gradebook's toolbar + and controls, and the Attendance dashboard's details run in the + extension's own isolated world, drawn in closed shadow roots that no script on + Toddle's page can read or click into. On the Attendance dashboard's Students tab, each student's year group and current class and teacher appear under their name, and the current block's column is outlined. It reads Toddle in the teacher's own browser, for the teacher at the screen, through an exact - allowlist of read-only queries sent only to Toddle, and never writes. - Nothing it reads leaves the browser; it transmits nothing from Toddle to - anyone: no account, no analytics, no tracking. Licence keys are checked - offline on the device; an individual key carries the buyer's email and an - organisation key the school's email domain, compared with the signed-in - Toddle account inside the browser only. An organisation licence is one key - for the school's email domain: anyone signed in to Toddle with an address - at that domain is covered. Student flags are hidden by default - and fetched only when a staff member chooses to show them. It has two - outside connections, neither carrying Toddle data. (1) Opt-in feedback: the - toolbar menu's "Send feedback" form, which, only when the person presses + allowlist of read-only queries sent only to Toddle, and never writes. It + reads, for the teacher's own account: assessment results, descriptors and + rubric levels as the school defined them in Toddle; class staff, for + primary teacher names; the Attendance dashboard's year groups and the + teacher of each student's current class; and, in the sidebar, the + student's details, contacts, classes, today's timetable and attendance, + and their flags. It notes the sign-in headers and + academic year from Toddle's own requests only to ask Toddle as the + teacher; it never asks for a password. Nothing it reads leaves the browser + except back to Toddle; it transmits nothing from Toddle to anyone else: no + account, no analytics, no tracking. Student photos load from wherever + Toddle serves them, as on Toddle's own page. Licence keys are checked + offline on the device; a key carries the buyer's email and may name who + it is for (the buyer's email, or for an organisation licence the school's + email domain), which is compared with the signed-in Toddle account inside + the browser only. An organisation licence is one key for the school's + email domain: anyone signed in to Toddle with an address at that domain is + covered. The sidebar asks Toddle for a student's + active flags each time it opens, and keeps them only while it is open. Apart + from Toddle, it has two outside connections, neither carrying Toddle + data. (1) Opt-in feedback: the toolbar menu's "Send feedback" form, which, only when the person presses Send, sends what they typed (topic, message, and an email only if they enter one) plus the extension's version number to Nyuchi's feedback form, processed by Formspree; no student data, no page address, no licence key. - (2) Only while a licence is entered, once a day, the background worker - makes one plain request to https://licences.nyuchi.dev/v1/revocations, + (2) Only while a licence is entered, at most once a day, the background + worker makes one plain request to https://licenses.nyuchi.com/v1/revocations + (versions before 0.9.0 asked licences.nyuchi.dev, the same server), sending no licence key, no identifiers, no cookies and no Toddle data; it downloads a Nyuchi-signed list of SHA-256 fingerprints of cancelled keys and checks its own key locally. Cloudflare sees the IP address as with any - web request; Nyuchi does not log it. If the check fails, the extension - keeps working. The welcome page it opens on - install is part of the extension and sends nothing. It stores, on the - user's own machine, only its switch settings and the licence key if there - is one; no student data is stored on the device. Rubric levels and + web request; Nyuchi does not log it. If the list cannot be fetched, the + last result stands. Anything else (a CSV file, an email or phone link, a + message in Toddle's own chat) happens only when the teacher does it. The + welcome page it opens on install is part of the extension and sends + nothing. It stores, on the user's own machine, its switch settings, the + licence key if there is one, the result of the last check for cancelled + keys, and the email of the Toddle account last seen signed in (to check + who a licence is for). The licence key alone is also + kept in Chrome's synced storage, so it follows the user's own Chrome + account to their other computers (Chrome's sync, not Nyuchi's; the newest + key wins, and removing it on one computer removes it on all); the + extension never sends the key to Nyuchi. And, in Toddle's own site storage, a copy of the + switches, whether flags are concealed, the My classes choice and the + academic year Toddle is showing. No student data is stored: what it reads stays in + the tab's memory until the tab is closed or reloaded. Rubric levels and descriptors are read from whatever a school has configured in Toddle; none - are defined in the extension. Security: https://learning.nyuchi.com/legal/security + are defined in the extension. Every query, stored value and request is + listed field by field in the extension's data schema, which is checked + against its code on every change; schools can ask privacy@nyuchi.com for + a copy. Schools deploy it from the Chrome Web Store by extension ID, with + a Google Admin console force-install (or Group Policy, Intune or a macOS + profile); an organisation key is pasted in once by each teacher, since it + cannot be pushed by policy. Teachers get each minor release (0.11.0 is + the current one: 0.10.0 with a security fix to how licence keys are + checked, so that a cancelled key cannot be made to work again by + writing it differently) from the Chrome Web Store; versions 0.8.3 to 0.8.13 were + test builds for schools piloting it, and their changes arrived together in + 0.9.0. Release + notes for every version, in plain words: + https://learning.nyuchi.com/toddle-enhancement-extension/releases + (each version at #v, e.g. #v0.9.0). There are no public + downloads; install from the Chrome Web Store. The extension is proprietary + and not open source: it and its code belong to Nyuchi Web Services, part + of Nyuchi Africa (Private) Limited, all rights reserved. A licence grants + use, not ownership; its code may not be copied, modified, reused or + redistributed, in whole or in part, in another product or service without + written permission. Reading the code to check what it does is welcome. + Terms: https://learning.nyuchi.com/legal/terms#ownership Help: + https://support.nyuchi.com/en/collections/19764027-how-to-use-the-toddle-enhancement-extension + Security: https://learning.nyuchi.com/legal/security + +- [Mukoko Lingo](https://learning.nyuchi.com/mukoko-lingo): a partner app + developed by Nyuchi Learning. Part of Mukoko by Nyuchi. Language + training for schools, businesses and government, and for individual + learners: AI-assisted, skills-based language learning across Africa. Use it in a web browser at + https://lingo.mukoko.com; organisations and their admins use the console + at https://lingo.nyuchi.com/console. It teaches Shona, Ndebele, Chinese + and English, with Swahili in beta. A learner gets five phrases a day, drilled with + flash cards and a short quiz, with spaced repetition scheduling what comes + back and when. Shamwari ("friend" in Shona), the AI tutor, answers + questions in chat and adapts to the learner's measured proficiency rather + than a level they picked; Shamwari never claims to be human. Pronunciation + guides, cultural context, and progress tracking (streaks, XP, bookmarks, + skill proficiency). For schools, businesses and government: teachers and trainers create classes, set assignments + against specific phrases and skills, and see progress per student; + assessments are diagnostic, formative and summative, graded on the server; + rosters sync over OneRoster from Clever, ClassLink and PowerSchool; + organisation admins manage users, content, moderation, guardrails, + analytics and API keys. Moderation runs in three layers: rule-based + guardrails, an AI-based check, and prompt-injection detection on anything + a learner types to the tutor. Sign-in is WorkOS AuthKit; the tutor runs on + Cloudflare Workers AI, called from Lingo's servers. Starting is free. + Open source under the MIT licence, (c) Nyuchi Learning: + https://github.com/mukoko-dev/mukoko-lingo. Enquiries: + support@nyuchi.com. ## Where the learning content went @@ -71,20 +158,18 @@ community-based platforms. Nyuchi Web Services is its development division. - [Nyuchi Learning programmes](https://nyuchi.com/learning): commercial cohort-based and self-paced training in software, AI and digital skills, localised for Africa with offline study packs. -- [Mukoko Lingo](https://mukoko.com/lingo): African language learning, free for - personal use. Shona and Ndebele primary. ## Legal -All dated 1 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. @@ -92,16 +177,22 @@ division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. stored value on the website, what sets it, how long it lasts and which consent choice enables it. The extension sets no cookies. - [Terms of use and service](https://learning.nyuchi.com/legal/terms): the - website's terms of use and the extension's terms of service (licences, - offline keys, refunds, acceptable use, liability, law of Zimbabwe). + website's terms of use and the extension's terms of service (who owns the + extension and its code, licences, offline keys, refunds, acceptable use, + liability, law of Zimbabwe). The extension and its code belong to Nyuchi + Web Services, all rights reserved; a licence grants use, not ownership, + and its code may not be reused in another product or service without + written permission. Refunds: a full refund within 30 days of purchase, for any reason related to the product; ask at support@nyuchi.com or through Buy Me a Coffee. A refunded licence key is cancelled and stops working after the extension's next daily check for cancelled keys. - [Data handling](https://learning.nyuchi.com/legal/data): for schools. What the extension reads, where it is processed (only in the teacher's browser), - what it stores (settings and licence key; no student data), what leaves the - browser (only requests to Toddle, plus opt-in feedback), what Nyuchi holds, + what it stores (settings and licence details; no student data), what leaves + the browser (requests to Toddle and student photos from Toddle, plus + opt-in feedback and the data-free daily cancelled-keys check), what Nyuchi + holds, the services that process it, retention, breach notification, and a data processing agreement on request. - [Student privacy](https://learning.nyuchi.com/legal/student-privacy): the @@ -113,14 +204,19 @@ division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. security model (runs only on web.toddleapp.com; permissions are storage and Toddle only; read-only allowlisted queries; sends only to Toddle except the opt-in feedback form and the daily cancelled-keys check; licence keys - checked offline; no student data stored), + checked offline, never sent to Nyuchi, and synced only through + the user's own Chrome account; no student data stored), how it is tested (closed-loop tests, adversarial-review regression tests, an end-to-end network recorder that fails on any non-Toddle request, every - release gated on them), the independent adversarial review of 1 October 2026 - (two High, one Medium and several Low findings, all fixed in 0.8.2 with - tests), and the residual limit (scripts Toddle itself loads share the page - and the teacher's access regardless of any extension; Toddle's pages send no - Content-Security-Policy). + release gated on them), two adversarial reviews (1 October 2026: two High, + one Medium and several Low findings, all fixed in 0.8.2 with tests; + 6 October 2026: two Medium and two Low findings in the code for 0.8.4, a + test release, all fixed there and in 0.9.0), and the residual limits (scripts Toddle + itself loads share the page and the teacher's access regardless of any + extension; Toddle's pages send no Content-Security-Policy; a script + already listening in Toddle's page can replay the extension's per-load + secrets in that browser or answer its queries with false data, but cannot + make anything leave the browser or write to Toddle). - [Vulnerability disclosure policy](https://learning.nyuchi.com/legal/vulnerability-disclosure): report to security@nyuchi.com with the subject "Security"; acknowledgement within 3 working days, triage within 10; safe harbour for good-faith @@ -150,7 +246,8 @@ Please keep one distinction straight, because it is the one people get wrong. This WEBSITE uses Google Analytics, behind a consent banner: it sets no cookies and measures nothing unless the visitor presses Accept. The EXTENSION does not measure anything at all, with or without consent. The extension contains no analytics, no telemetry and no -tracking, and nothing it reads leaves the browser it runs in. Its only outside +tracking, and nothing it reads leaves the browser it runs in, except back to +Toddle. Its only outside connections are feedback a person writes and chooses to send from its menu, and, while a licence is entered, a daily download of a signed list of cancelled keys that sends no data. A question about 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/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..82e4204 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * Keeps this clone light: `npm run clean`, and run on its own after a pull + * request merges (the Claude Code hook in .claude/settings.json) and after + * every `git pull` that merges (.githooks/post-merge). + * + * The same script is in nyuchi/toddle-enhancement-extension, nyuchi/learning + * and nyuchi/nyuchi-licence-server; only GENERATED differs. It removes: + * + * 1. what the build, the tests and the tools generate (GENERATED below), + * and only where Git ignores it, so a tracked or new file is never + * touched; + * 2. Claude Code worktrees (.claude/worktrees/) with nothing uncommitted; + * one with changes is left, and named; + * 3. local branches that are done: everything on them is already in + * origin/main or origin/staging (merged, or squash-merged: merging the + * branch in would change nothing), or their remote branch was deleted + * after its pull request merged (GitHub deletes head branches on merge) + * while the local branch held nothing that had not been pushed. main, staging, the branch + * checked out and any branch in a worktree stay. + * + * --deep also removes node_modules and empties npm's cache (`npm ci` brings + * both back). --dry-run lists what it would remove. --no-fetch skips + * `git fetch --prune` (offline). --quiet prints the summary line only. + * --hook is for Claude Code's PostToolUse hook: it reads the hook's JSON on + * stdin and cleans the repository whose pull request was merged, when that + * clone sits next to this one. It never fails: a hook or a pull must not + * break on a cleanup. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* this repository's generated paths, relative to its root */ +const GENERATED = [ + "dist", + ".astro", + ".vercel/output", + "qa-report.json", + "node_modules/.cache", + "node_modules/.vite", + "node_modules/.astro", +]; +const KEEP = new Set(["main", "staging"]); + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const args = new Set(process.argv.slice(2)); +const dry = args.has("--dry-run"); +const quiet = args.has("--quiet"); +const say = (line) => { + if (!quiet) console.log(`clean: ${line}`); +}; + +function git(...a) { + return execFileSync("git", a, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 60_000, + }).trim(); +} +function tryGit(...a) { + try { + return git(...a); + } catch { + return null; + } +} +const lines = (s) => (s ? s.split("\n").filter(Boolean) : []); + +/* --hook: clean the repository the merged pull request belongs to */ +function fromHook() { + let input = {}; + try { + input = JSON.parse(readFileSync(0, "utf8") || "{}"); + } catch { + /* no input: clean this clone */ + } + const out = JSON.stringify(input.tool_response ?? ""); + if (!/merged\\?"\s*:\s*true/.test(out)) return; // not merged: nothing to do + const repo = String(input.tool_input?.repo ?? ""); + const project = process.env.CLAUDE_PROJECT_DIR || root; + const candidates = [ + project, + join(project, repo), + join(dirname(project), repo), + join(dirname(root), repo), + ]; + const target = + candidates.find( + (d) => + repo && + basename(d) === repo && + existsSync(join(d, "scripts", "clean.mjs")), + ) ?? (repo ? null : root); + if (!target) return; // that repository is not cloned here + spawnSync(process.execPath, [join(target, "scripts", "clean.mjs")], { + cwd: target, + stdio: ["ignore", "inherit", "inherit"], + timeout: 120_000, + }); +} + +function sizeOf(path) { + const r = spawnSync("du", ["-sk", path], { encoding: "utf8" }); + return r.status === 0 ? Number(r.stdout.split("\t")[0]) * 1024 : 0; +} +const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(0)} MB`; + +function clean() { + if (tryGit("rev-parse", "--show-toplevel") === null) + return say("not a Git clone; nothing to do"); + let freed = 0; + const removed = []; + const left = []; + + /* 1. generated paths, only where Git ignores them */ + const paths = [...GENERATED]; + if (args.has("--deep")) paths.push("node_modules"); + for (const rel of paths) { + const abs = join(root, rel); + if (!existsSync(abs)) continue; + if (tryGit("check-ignore", "-q", rel) === null) { + left.push(`${rel} (not ignored by Git)`); + continue; + } + freed += sizeOf(abs); + if (!dry) rmSync(abs, { recursive: true, force: true }); + removed.push(rel); + } + + /* 2. Claude Code worktrees with nothing uncommitted */ + const inWorktree = new Set(); + const trees = lines(tryGit("worktree", "list", "--porcelain")).reduce( + (all, l) => { + if (l.startsWith("worktree ")) all.push({ path: l.slice(9) }); + else if (l.startsWith("branch ") && all.length) + all[all.length - 1].branch = l.slice(7).replace("refs/heads/", ""); + return all; + }, + [], + ); + for (const t of trees) { + const ours = t.path.includes(join(".claude", "worktrees")); + if (!ours) { + if (t.branch) inWorktree.add(t.branch); + continue; + } + const dirty = spawnSync("git", ["status", "--porcelain"], { + cwd: t.path, + encoding: "utf8", + }); + if (dirty.status !== 0 || dirty.stdout.trim()) { + if (t.branch) inWorktree.add(t.branch); + left.push(`${t.path} (uncommitted changes)`); + continue; + } + freed += sizeOf(t.path); + if (!dry) tryGit("worktree", "remove", t.path); + removed.push(`worktree ${basename(t.path)}`); + } + if (!dry) tryGit("worktree", "prune"); + + /* 3. local branches that are done */ + const before = new Map( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3) %(objectname)", + "refs/remotes/origin", + ), + ).map((l) => l.split(" ")), + ); + if (!args.has("--no-fetch")) + tryGit( + "fetch", + "--quiet", + "--prune", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ); + const after = new Set( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3)", + "refs/remotes/origin", + ), + ), + ); + const current = tryGit("branch", "--show-current"); + const bases = ["origin/main", "origin/staging"].filter( + (b) => tryGit("rev-parse", "--verify", "-q", b) !== null, + ); + const isAncestor = (a, b) => + tryGit("merge-base", "--is-ancestor", a, b) !== null; + /* nothing to bring in: merging the branch into the base changes nothing, + which holds for a squash merge too */ + const alreadyIn = (a, b) => { + const tree = tryGit("merge-tree", "--write-tree", b, a); + return tree !== null && tree === tryGit("rev-parse", `${b}^{tree}`); + }; + for (const l of lines( + tryGit( + "for-each-ref", + "--format=%(refname:short) %(upstream:short) %(upstream:track)", + "refs/heads", + ), + )) { + const [branch, upstream = "", ...track] = l.split(" "); + if (KEEP.has(branch) || branch === current || inWorktree.has(branch)) + continue; + const remote = upstream.replace(/^origin\//, ""); + const pushedThenDeleted = + upstream.startsWith("origin/") && + before.has(remote) && + !after.has(remote) && + isAncestor(branch, before.get(remote)); + const merged = bases.some( + (b) => isAncestor(branch, b) || alreadyIn(branch, b), + ); + if (pushedThenDeleted || merged) { + if (!dry) tryGit("branch", "-D", branch); + removed.push(`branch ${branch}`); + } else if (track.join(" ").includes("gone")) { + left.push( + `branch ${branch} (its remote branch is gone; check it holds nothing unpushed)`, + ); + } + } + + if (args.has("--deep") && !dry) { + spawnSync("npm", ["cache", "clean", "--force"], { stdio: "ignore" }); + removed.push("npm cache"); + } + + const verb = dry ? "would remove" : "removed"; + console.log( + `clean (${basename(root)}): ${removed.length ? `${verb} ${removed.join(", ")}` : "nothing to remove"}${freed ? `, ${mb(freed)}` : ""}`, + ); + for (const l of left) say(`left ${l}`); +} + +try { + if (args.has("--hook")) fromHook(); + else clean(); +} catch (e) { + console.log(`clean: stopped (${e?.message ?? e}); nothing else was changed`); +} +process.exit(0); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 0000000..3609c1f --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Turns on this repository's Git hooks (`.githooks/`) in this clone. + * `npm ci` and `npm install` run it, as `prepare`. + * + * All it does is set `core.hooksPath` to `.githooks` in this clone's own + * `.git/config`: it enables hooks and bypasses nothing. Because the hooks + * are tracked files, Git runs the version on the branch you have + * checked out, as `npm test` and `npm ci` already run that branch's code: + * check out a branch you don't trust only in a clone you'd run its tests + * in (CONTRIBUTING.md). It changes nothing, + * and says so, where that would silence hooks a developer already has: + * `core.hooksPath` set at any level (a global secret scanner, say), or a + * hook of their own in `.git/hooks/`. Outside a Git clone of this + * repository (a packed copy, or no git at all) it does nothing, and it + * never fails an install. + * + * The one hook, .githooks/post-merge, runs scripts/clean.mjs after a + * `git pull` that merges. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HOOKS = ".githooks"; + +const git = (...args) => + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +const quiet = (fn) => { + try { + return fn(); + } catch { + return ""; + } +}; +const note = (text) => + console.log(`git hooks: ${text} This repository's hooks are in ${HOOKS}/; + to use them: git config core.hooksPath ${HOOKS} (CONTRIBUTING.md).`); + +function install() { + const top = quiet(() => git("rev-parse", "--show-toplevel")); + /* not a Git clone, no git, or a clone of something else around us + (native realpath: the case on disk, so the two spellings agree) */ + if (!top || realpathSync.native(top) !== realpathSync.native(root)) return; + + /* core.hooksPath from any level: system, global or this clone */ + const current = quiet(() => git("config", "--get", "core.hooksPath")); + if (current === HOOKS) return; + if (current) return note(`core.hooksPath is ${current}; left as it is.`); + + /* hooks of a developer's own in .git/hooks/ (Git's *.sample files are + not hooks) */ + const dir = quiet(() => git("rev-parse", "--git-path", "hooks")); + const hooksDir = dir && (isAbsolute(dir) ? dir : join(root, dir)); + const own = + hooksDir && existsSync(hooksDir) + ? readdirSync(hooksDir).filter((n) => !n.endsWith(".sample")) + : []; + if (own.length) + return note( + `${hooksDir} has hooks of its own (${own.join(", ")}); left as it is.`, + ); + + try { + git("config", "--local", "core.hooksPath", HOOKS); + console.log(`git hooks: on (${HOOKS}/, see CONTRIBUTING.md)`); + } catch { + note("could not set core.hooksPath (is .git/config writable?)."); + } +} + +try { + install(); +} catch { + /* an install never fails on an optional hook */ +} 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/assets/lingo/icon.png b/src/assets/lingo/icon.png new file mode 100644 index 0000000..006d0e9 Binary files /dev/null and b/src/assets/lingo/icon.png differ 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 && ( - <> -