From ee5a0e8d1ef35b1d8d6a4893073c5a46724eacaa Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sun, 4 Oct 2026 20:27:56 +0800 Subject: [PATCH 01/20] ci: tag staging merges as patches and run the build on staging (#64) Every merge into `staging` (the live beta) is released as the next patch under the org versioning policy (nyuchi/.github#80), through the pinned reusable-staging-release.yml. The build workflow now also runs on pushes to `staging`, so the beta branch carries the same checks as master. Claude-Session: https://claude.ai/code/session_017T4NxM5wbhJ3LjHt7bnuwr Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- .github/workflows/staging-version.yml | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/staging-version.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff2c504..c169327 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: build on: pull_request: push: - branches: [master] + branches: [master, staging] permissions: contents: read diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml new file mode 100644 index 0000000..4cc02fa --- /dev/null +++ b/.github/workflows/staging-version.yml @@ -0,0 +1,25 @@ +# 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 + +permissions: + contents: read + +jobs: + version: + uses: nyuchi/.github/.github/workflows/reusable-staging-release.yml@924e5b4d0983d31379aedbec02b990793f29c792 # main, 2026-10-04 + with: + bump: ${{ inputs.bump || '' }} + permissions: + contents: write From 1b9c5da161e8a0f3b058d47ba6776b71e00d2379 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Sun, 4 Oct 2026 21:49:34 +0800 Subject: [PATCH 02/20] chore: the default branch is main (#65) * chore: the default branch is main The default branch was renamed from master to main on 2026-10-04. Point the build trigger and the README at it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017T4NxM5wbhJ3LjHt7bnuwr * ci: build on pushes to main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017T4NxM5wbhJ3LjHt7bnuwr --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c169327..f63f453 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: build on: pull_request: push: - branches: [master, staging] + branches: [main, staging] permissions: contents: read diff --git a/README.md b/README.md index 0b64c8a..5251753 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![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:** 3.0.0 | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Default branch:** `main` | **Deploy:** Vercel --- @@ -54,7 +54,7 @@ every Nyuchi surface at once. ## Hosting -Vercel, from `master`. The site is fully static — every page is known at build +Vercel, from `main`. The site is fully static — every page is known at build time, so nothing renders per request. Response headers, including the CSP, are declared in `vercel.json`. From 5883db517f51b8d8e0789890c8a201e34b5f8d22 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Tue, 6 Oct 2026 07:46:40 +0800 Subject: [PATCH 03/20] docs(legal): privacy on Zimbabwe's Act and the GDPR; every extension claim matches its code (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(legal): every claim about the extension matches its code, for 0.8.2 and 0.8.4 Checked against the extension at 75876d4 (docs/toddle-data-fields.md, the security reviews, SECURITY.md, licence.js, background.js and the code). - Flags: from 0.8.4 they are shown as Toddle shows them and the free switch hides them everywhere; before 0.8.4 they were hidden by default. Unless flags are hidden, the sidebar asks Toddle for them each time it opens. - Memory: answers stay in the tab until it is closed or reloaded, reused for at most 5 minutes (2 for a student's day); never written to storage. - Storage: licenceCheck and licenceViewer in the extension's storage, and the four values on Toddle's site, each described. Drops a "message button style" value the code does not have. - Switches: adds gradebook tools and the Attendance dashboard's details. - Licence keys may, not must, name who they are for. - Reads: gradebook, home page, profile page and Attendance dashboard, plus the sign-in headers and academic year noted from Toddle's own requests. - What leaves the device, listed exactly, student photos included. - Security page: the review of 6 October 2026 (findings 13 to 16) and what code inside Toddle's page cannot promise; says which versions it covers. - Version facts in one place (extensionVersions); dated 6 October 2026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CoDNYz27iJ7o8PPztCzTpq * docs(legal): privacy rests on Zimbabwe's Act and the GDPR; fix missing spaces - The privacy policy names the two laws Nyuchi holds itself to: Zimbabwe's Cyber and Data Protection Act [Chapter 12:07], and the EU and UK GDPR, applied to everyone. "The law we follow" names POTRAZ, says where data goes and how transfers out of the EU and UK are covered. 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. - Breaches go to POTRAZ within 24 hours (Zimbabwe's Act), and to an EU or UK regulator within 72 where the GDPR requires it (privacy and data pages). - Student privacy names both laws. Singapore's PDPA is no longer named, in the pages or the consent notes. The facts live once, in `dataProtection`. - compressHTML off: Astro's compression dropped the space where a line break sat next to an inline tag ("Applies to…"), about 90 times across the legal and product pages. Now none; CSP hashes unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CoDNYz27iJ7o8PPztCzTpq --------- Co-authored-by: Bryan Fawcett --- CHANGELOG.md | 34 +++ SECURITY.md | 6 +- astro.config.mjs | 4 + public/llms.txt | 93 ++++--- src/components/CookieBanner.astro | 2 +- src/data/consent.ts | 17 +- src/data/legal.ts | 129 ++++++++-- src/pages/legal/cookies.astro | 20 +- src/pages/legal/data.astro | 159 +++++++++--- src/pages/legal/privacy.astro | 142 ++++++++--- src/pages/legal/security.astro | 240 ++++++++++++++---- src/pages/legal/student-privacy.astro | 19 +- src/pages/legal/terms.astro | 22 +- .../legal/vulnerability-disclosure.astro | 7 +- src/pages/toddle-enhancement-extension.astro | 16 +- tests/security.test.ts | 2 +- 16 files changed, 682 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2e3fa..65830d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ ### Changed +- **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 + are dated 6 October 2026. 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/SECURITY.md b/SECURITY.md index 25a4278..73d3566 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,8 +3,10 @@ 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, before it is +published), 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 diff --git a/astro.config.mjs b/astro.config.mjs index 950056e..e90682d 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 diff --git a/public/llms.txt b/public/llms.txt index 6d86cc1..6d7b550 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -19,13 +19,15 @@ community-based platforms. Nyuchi Web Services is its development division. 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, + a flag being visible. From version 0.8.4, flags are shown as Toddle shows + them, and a staff member can hide them everywhere with the switch, which is + free; an eye button then shows one student's flags. Versions before 0.8.4 + hid them by default. 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, the + Attendance dashboard's details, and the student sidebar. The sidebar + (since 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 @@ -37,30 +39,46 @@ community-based platforms. Nyuchi Web Services is its development division. 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 when they are shown. 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. Unless flags are hidden, the sidebar asks Toddle for a student's + 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://licences.nyuchi.dev/v1/revocations, 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); and, in Toddle's own site storage, a copy of the + switches, whether flags are hidden, 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 @@ -76,7 +94,7 @@ community-based platforms. Nyuchi Web Services is its development division. ## Legal -All dated 1 October 2026. Operated by Nyuchi Web Services, the development +All dated 6 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 @@ -100,8 +118,10 @@ division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. 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 @@ -116,11 +136,15 @@ division of Nyuchi Africa (Private) Limited, Harare, Zimbabwe. checked offline; 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, all + fixed before it is published), 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 +174,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/src/components/CookieBanner.astro b/src/components/CookieBanner.astro index d204c3a..5edf295 100644 --- a/src/components/CookieBanner.astro +++ b/src/components/CookieBanner.astro @@ -11,7 +11,7 @@ * 2. Nothing optional starts on. Every toggle in the panel is unchecked until * someone checks it, so closing the banner without answering leaves you * where rejecting does. - * 3. The record carries a timestamp and a version. All three regimes put the + * 3. The record carries a timestamp and a version. Both laws (legal.ts) put the * burden on us to show consent was given, and "the cookie was there" is * not that. * diff --git a/src/data/consent.ts b/src/data/consent.ts index a9bbe18..d976f6b 100644 --- a/src/data/consent.ts +++ b/src/data/consent.ts @@ -1,8 +1,9 @@ /** * What this site asks consent for, declared once. * - * Three regimes apply to the people who read this site — staff at - * international schools — and they agree on more than they differ: + * Two laws set the standard (legal.ts, dataProtection): Zimbabwe's Act, + * because Nyuchi is Zimbabwean, and the EU and UK GDPR, the most widely used, + * applied to everyone. They agree on more than they differ: * * UK/EU GDPR consent must be freely given, specific, informed * and unambiguous; granular per purpose, not bundled; @@ -11,12 +12,8 @@ * Zimbabwe CDPA 12:07 "freely given specific and informed indication"; * withdrawal; the controller must be able to show * consent was obtained - * Singapore PDPA purposes notified before collection; consent not a - * condition of service beyond what is reasonable; - * withdrawal honoured * - * The shared requirements drive the design, so one implementation serves all - * three: nothing optional is on until it is chosen, each purpose is chosen + * The shared requirements drive the design, so one implementation serves both: nothing optional is on until it is chosen, each purpose is chosen * separately, refusing everything is one press, and the record carries a * timestamp and a version so it can be produced later. * @@ -27,9 +24,9 @@ * two third parties. * * This is the engineering, not legal advice. Someone qualified should read the - * privacy policy before it is relied on, and the PDPA separately expects a named - * data protection officer whose business contact details are published — that is - * a person to appoint, not a thing to code. + * privacy policy before it is relied on. Zimbabwe's licensing regulations for + * data controllers also expect a data protection officer to be appointed — a + * person to appoint, not a thing to code. */ export type ConsentCategory = { diff --git a/src/data/legal.ts b/src/data/legal.ts index 15d156c..29b02b4 100644 --- a/src/data/legal.ts +++ b/src/data/legal.ts @@ -10,7 +10,7 @@ export const legal = { /** Last substantive change, for every legal page. Update when a claim changes, not on typos. */ - updated: "2026-10-03", + updated: "2026-10-06", entity: "Nyuchi Africa (Private) Limited", shortEntity: "Nyuchi", country: "Zimbabwe", @@ -31,7 +31,37 @@ export const legal = { support: { intercomAppId: "f1vga504" }, } as const; -/** The date above as people write it: "1 October 2026". */ +/** + * The two data protection laws Nyuchi holds itself to, said once. Zimbabwe's + * Act because Nyuchi is a Zimbabwean company; the EU and UK GDPR because they + * are the most widely used standard, so we apply them to everyone, wherever + * they are. Where the two differ, the stricter one wins (the breach deadline: + * Zimbabwe's 24 hours to the regulator is shorter than the GDPR's 72). + */ +export const dataProtection = { + home: { + law: "Cyber and Data Protection Act [Chapter 12:07]", + authority: + "the Postal and Telecommunications Regulatory Authority of Zimbabwe (POTRAZ)", + authorityShort: "POTRAZ", + authorityUrl: "https://www.potraz.gov.zw", + /** Notice to the regulator after a breach is discovered (s. 19). */ + breachHours: 24, + }, + gdpr: { + /** Notice to a supervisory authority, where a breach must be notified. */ + breachHours: 72, + /** Time to answer a request about your data, free of charge. */ + answerWithin: "one month", + ukAuthority: "the Information Commissioner's Office (ICO)", + ukAuthorityUrl: "https://ico.org.uk/make-a-complaint/", + /** Every EU and EEA supervisory authority, from the EDPB. */ + euAuthoritiesUrl: + "https://www.edpb.europa.eu/about-edpb/about-edpb/members_en", + }, +} as const; + +/** The date above as people write it: "6 October 2026". */ export const updatedLabel = new Date( `${legal.updated}T00:00:00Z`, ).toLocaleDateString("en-GB", { @@ -76,10 +106,29 @@ export const pricing = [ }, ] as const; +/** + * Which versions of the extension the legal pages describe. `current` is what + * teachers have from the Chrome Web Store (the v0.8.3 tag carries the same + * code). `next` is the release under Unreleased in the extension's changelog: + * reviewed, not yet published. Claims that differ between the two say "from + * version 0.8.4", so they stay true on both sides of the release. When `next` + * ships, move it to `current` and drop the "before" wording. + */ +export const extensionVersions = { + current: "0.8.2", + sameCodeAs: "0.8.3", + next: "0.8.4", + /** The oldest version security reports are accepted for. */ + supportedFrom: "0.8.2", +} as const; + +const { next } = extensionVersions; + /** * What the extension does with data. Each of these is a claim that can be - * checked against the source, and each maps to an answer on the Chrome Web - * Store privacy form. + * checked against the source (the extension's docs/toddle-data-fields.md is + * the full schema, checked against its code), and each maps to an answer on + * the Chrome Web Store privacy form. */ export const extensionDataFacts = [ { @@ -90,27 +139,27 @@ export const extensionDataFacts = [ { claim: "It reads; it does not write.", detail: - "It reads what Toddle already lets you see — gradebook results, class staff, student details — through Toddle's own interface, reusing the session you are already signed in with. Every request it may send is listed word for word in the extension, and anything else is refused; write operations are blocked outright rather than merely avoided, so it cannot change your gradebook even by accident. Its message buttons open Toddle's own chat window and send nothing themselves: a message is sent, if at all, by you, through Toddle, under your school's messaging rules.", + "It reads what Toddle already lets you see, through Toddle's own interface, reusing the session you are already signed in with. Every request it may send is listed word for word in the extension, and anything else is refused. Write operations are blocked outright rather than merely avoided, so it cannot change your gradebook even by accident. On the home page and the Attendance dashboard it adds a few fields to Toddle's own request instead of sending a second one, and takes them out again before Toddle's page sees the answer. Its message buttons open Toddle's own chat window and send nothing themselves: a message is sent, if at all, by you, through Toddle, under your school's messaging rules.", }, { - claim: "What it reads for the student sidebar, and when.", + claim: "What it reads, feature by feature.", detail: - "Only when a teacher opens a student or a class, and only for the teacher at the screen: the student's name, photo, year group and age (the date of birth itself is never shown), email, student ID and enrolment date; their contacts' names, relationships, phone numbers and emails; their homeroom advisor; the school's Student Group and Room number fields (other additional fields are never shown); today's timetable and attendance marks; and each class's teachers with their display titles and emails. Toddle answers with what that teacher's own account is allowed to see. Nothing is written to disk: answers are held in the page's memory for a few minutes, then discarded, and are gone when the tab closes.", + "Toddle answers with what the signed-in teacher's own account may see, and no more. The gradebook tools, when a teacher expands an assessment: its assessment tools as Toddle defines them (rubric criteria, descriptors and levels, grade scales and their colours, checklist items, scores, standards and learning goals), and each assigned student's name, email, student ID, submission status and results, including written responses and comments. No descriptor or level is written in the extension. The home page: each class's staff, to show its primary teacher and the My classes filter. A student's profile page: the teachers of each of the student's classes. The Attendance dashboard: each student's year group and the names of the periods, from Toddle's own request, and the primary teacher of the class each student is in now, for the “Now: class · teacher” line. The student sidebar, when a teacher opens a student or a class: the student's name, photo, year group and age (the date of birth is used to work out the age and is never shown), email, student ID and enrolment date; their family accounts and contacts, with names, relationships, phone numbers and emails; their homeroom advisor; the school's additional profile fields, of which only Student Group and Room number are ever shown; today's timetable and attendance marks; their active flags, when flags are shown; and each class's teachers, with their display titles and emails.", }, { - claim: "Student flags stay hidden until someone chooses to show them.", - detail: - "Flags are hidden by default across Toddle, on a new install and for anyone who never touched the switch; a staff member shows them deliberately, with the switch or with the eye button for one student. The extension's own sidebar fetches a student's flags from Toddle only when someone chooses to show them, and forgets them when the sidebar closes. The flag switch is free and always will be.", + claim: + "Student flags: shown as Toddle shows them, hidden with one free switch.", + detail: `From version ${next}, flags are shown as Toddle shows them. A staff member can hide them everywhere in Toddle with the switch, in Toddle's top bar or the extension's menu; the eye button then shows one student's flags. Versions before ${next} hid flags by default. Unless flags are hidden, the sidebar asks Toddle for a student's active flags each time it opens; while they are hidden, it asks only when someone presses the eye button. It keeps them only while that student's sidebar is open. Hiding flags changes only what is drawn on the screen, never Toddle's data. The flag switch is free and always will be.`, }, { - claim: "It never sees your password.", + claim: "Your Toddle sign-in stays with Toddle.", detail: - "Authentication stays inside Toddle's own page. The part of the extension that draws the interface cannot read the authentication token, by design.", + "The extension never asks for your password and never stores it. To ask Toddle as you, it notes the sign-in headers from Toddle's own requests, and the academic year Toddle is showing. The headers are held in the tab's memory, in one script inside Toddle's page: the extension's other parts never see them, they are never stored, and they are sent nowhere but Toddle's own interface. The academic year number is kept in Toddle's site storage (see below) so the sidebar asks for the right year, and is sent nowhere but Toddle.", }, { - claim: "Nothing it reads leaves your browser.", + claim: "Exactly what leaves your device.", detail: - "There is no account, no analytics, no telemetry, no tracking, no advertising and no remote code. No student data, no teacher data and no school data leaves your browser: what it reads from Toddle goes back to Toddle's own screen and nowhere else. It reads Toddle for the teacher at the screen, and for no one else. It makes two connections outside Toddle, described next, and neither carries anything from Toddle.", + "Nothing it reads from Toddle goes anywhere but back to Toddle. There is no account, no analytics, no telemetry, no tracking, no advertising and no remote code. The extension makes these requests and no others: read-only requests to Toddle's own interface, with your Toddle session; student photos, loaded from the address Toddle gives for each one, wherever Toddle serves them, as Toddle's own page does; the feedback form, only when you press Send; and, only while a licence key is entered, the daily check for cancelled keys, which carries nothing. The last two are described next. Anything else happens only when you do it: saving a CSV file, opening an email or phone link, or sending a message in Toddle's own chat. Like any web request, each one lets the server that receives it see your IP address and browser type.", }, { claim: "Outside connection one, only when you choose it: feedback.", @@ -123,41 +172,65 @@ export const extensionDataFacts = [ "When you first install it, the extension opens a welcome page. That page is part of the extension itself, not a website, and loading it contacts nothing.", }, { - claim: "The only things it stores are your settings and your licence key.", + claim: "What it stores: settings and licence details, and no student data.", + detail: `In Chrome's storage for the extension, on your device: your switches (the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on the Attendance dashboard); your licence key, if you entered one; the result of the last check for cancelled keys (whether your key is on the list, the list's date and when the check was made, never the list itself); and the email address of the Toddle account last seen signed in, with the time, so the extension can check who a licence is for. That email is read on every Toddle page, with or without a licence key, and stays on the device. In the browser's storage for Toddle's own site: tee-settings, a copy of your switches and of which features the licence allows (no key and no email); gbx-hide-flags, whether flags are hidden, so they are hidden before the page is drawn; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. Nothing it reads about students, classes, results or attendance is ever written to storage. Versions before ${next} have three switches: student flags, the student sidebar and primary teacher names.`, + }, + { + claim: "What it holds in memory, and for how long.", detail: - "Your switch preferences — student flags shown or hidden, the student sidebar, primary teacher names, the My classes filter — and the licence key if you have one. All of it is stored on your own machine, and none of it is sent anywhere. No student data is stored on the device at all.", + "Answers from Toddle are held in the Toddle tab's memory, never written to storage, until the tab is closed or reloaded or the browser quits. Moving between Toddle pages without a reload does not clear them. A student's or a class's details are reused for at most 5 minutes, and a student's day for at most 2, before Toddle is asked again. A student's flags are never reused, and are kept only while that student's sidebar is open.", }, { claim: "Your licence is checked on your machine, not by asking us.", detail: - "A licence key is a signed statement that the extension verifies locally. An individual key carries the buyer's email address, and an organisation key the school's email domain; the extension compares that with the Toddle account signed in, inside the browser only, and sends it nowhere. Activating a key contacts nothing, which is why it works without a connection.", + "A licence key is a signed statement that the extension verifies locally. It carries the buyer's email address. A key may also name who it is for: the buyer's email, or for an organisation licence the school's email domain. Where it does, the extension compares that with the Toddle account signed in, inside the browser only, and sends it nowhere. Activating a key contacts nothing, which is why it works without a connection.", }, { claim: "Outside connection two, only while a licence is entered: a daily check for cancelled keys.", detail: - "Once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licences.nyuchi.dev/v1/revocations. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, and checks its own key against that list on your machine. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the check fails, the extension keeps working.", + "At most once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licences.nyuchi.dev/v1/revocations; a key just entered is checked within 5 minutes. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, checks its own key against that list on your machine, and keeps only the result. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the list cannot be fetched, the last result stands, so a network outage never switches a licence off.", }, { claim: "Exports are local.", detail: - "The CSV export is generated in your browser and saved by your browser, to wherever your downloads go. It does not pass through any service of ours.", + "The CSV export is generated in your browser and saved by your browser, to wherever your downloads go. It does not pass through any service of ours. Once saved, the file is yours, under your school's own rules for files.", + }, + { + claim: "Removing it removes what it stored.", + detail: + "Uninstalling the extension deletes everything in its own storage. The four small values on Toddle's site stay until the browser's data for web.toddleapp.com is cleared; none of them is about a student. Removing a licence key deletes the key; the last check's result and the Toddle account's email stay until the extension is uninstalled.", }, ] as const; /** * The extension's security posture, for the Security page. Each line is a - * claim about the 0.8.2 build that can be checked against its source and - * tests; when the extension changes, this changes with it. + * claim about the published build (`current`) or the reviewed next one + * (`next`) that can be checked against the extension's source, tests, + * SECURITY.md and docs/security-review.md; when the extension changes, this + * changes with it. */ export const security = { - /** The extension version these statements describe. */ - version: "0.8.2", - /** The independent adversarial review. */ - review: { - date: "2026-10-01", - label: "1 October 2026", - }, + /** The published version these statements describe. */ + version: extensionVersions.current, + /** The reviewed version not yet published, which they also describe. */ + next: extensionVersions.next, + supportedFrom: extensionVersions.supportedFrom, + /** The adversarial reviews, oldest first. */ + reviews: [ + { + date: "2026-10-01", + label: "1 October 2026", + version: extensionVersions.current, + findings: "1 to 12", + }, + { + date: "2026-10-06", + label: "6 October 2026", + version: extensionVersions.next, + findings: "13 to 16", + }, + ], /** How to report a vulnerability. */ report: { email: "security@nyuchi.com", diff --git a/src/pages/legal/cookies.astro b/src/pages/legal/cookies.astro index a402efe..312cd45 100644 --- a/src/pages/legal/cookies.astro +++ b/src/pages/legal/cookies.astro @@ -193,13 +193,19 @@ const rows = [ The extension

- The {toddleExtension.name} sets no cookies and contains no analytics. It keeps your - switch settings and your licence key, if you have one, in Chrome's storage for the - extension, on your own device. On Toddle's own site it also keeps a few housekeeping - values in the browser's storage: a copy of the switches and of which features the - licence allows (not the key), whether flags are hidden, the My classes choice, the - number of the academic year Toddle is showing, and the style of Toddle's message - button. None of it is about a student, and none of it is sent anywhere. The + The {toddleExtension.name} sets no cookies and contains no analytics. In Chrome's + storage for the extension, on your own device, it keeps your switch settings, your + licence key if you have one, the result of its last check for cancelled keys, and the + email address of the Toddle account last seen signed in, so it can check who a + licence is for. On Toddle's own site it keeps four small values in the browser's + storage: tee-settings, a copy of the switches and of which features the + licence allows (not the key, and no email); gbx-hide-flags, whether + flags are hidden; tee-course-view, whether you chose My classes in the + home page filter; and tee-academic-year, the number of the academic year + Toddle is showing. None of it is about a student, and the extension sends none of it + anywhere. + Uninstalling the extension deletes its own storage; the four values on Toddle's site + stay until you clear the browser's data for Toddle's site. The data handling and security pages have the detail.

diff --git a/src/pages/legal/data.astro b/src/pages/legal/data.astro index 2773f11..9a3bd6c 100644 --- a/src/pages/legal/data.astro +++ b/src/pages/legal/data.astro @@ -9,7 +9,13 @@ * (nyuchi-licence-server, schema.sql). Nothing here is a legal conclusion. */ import BaseLayout from "../../layouts/BaseLayout.astro"; -import { legal, refunds, updatedLabel } from "../../data/legal"; +import { + dataProtection, + extensionVersions, + legal, + refunds, + updatedLabel, +} from "../../data/legal"; import { toddleExtension } from "../../data/site"; const title = "Data handling — Nyuchi Learning"; @@ -103,59 +109,130 @@ const subprocessors = [ class="mt-4 space-y-2 text-body text-muted-foreground list-disc list-outside pl-5 marker:text-foreground/40" >
  • - The gradebook: assessments, their criteria - and scales, and each student's results and submission status, to draw per-criterion - columns and the CSV export. + The gradebook, when a teacher expands an + assessment: its tools as Toddle defines them (rubric criteria, descriptors and + levels, grade scales and their colours, checklist items, scores, standards and + learning goals), and each student's name, email, student ID, submission status and + results, including written responses and comments, to draw per-criterion columns + and the CSV export. No descriptor or level is written in the extension.
  • - Classes and staff: each class's teachers, - with display titles and emails, to show primary teachers, My classes and the class - view. + The home page: each class's staff, to show + its primary teacher and the My classes filter. On a student's profile page, the + teachers of each of the student's classes. +
  • +
  • + The Attendance dashboard: each student's + year group and the names of the periods, from Toddle's own request, and the primary + teacher of the class each student is in now, for the “Now: class · teacher” line.
  • The student sidebar, when a teacher opens - a student: name, photo, year group and age (the date of birth is never shown), - email, student ID, enrolment date, contacts with relationship, phone numbers and - email, homeroom advisor, the school's Student Group and Room number fields, and - today's timetable and attendance marks. + a student or a class: name, photo, year group and age (the date of birth is used to + work out the age and is never shown), email, student ID, enrolment date, family + accounts and contacts with relationship, phone numbers and email, homeroom advisor, + the school's additional profile fields (only Student Group and Room number are ever + shown), today's timetable and attendance marks, and each class's teachers with + their display titles and emails. +
  • +
  • + Student flags. From version {extensionVersions.next}, + flags are shown as Toddle shows them, and a staff member can hide them everywhere + with the free flag switch; versions before {extensionVersions.next} hid them by + default. Unless flags are hidden, the sidebar + asks Toddle for a student's active flags each time it opens; while they are + hidden, only when someone presses its eye button. It keeps them only while that + student's sidebar is open. +
  • +
  • + From Toddle's own requests: the sign-in + headers, so it can ask Toddle as the teacher, and the academic year Toddle is + showing, so the sidebar asks for the right year. The headers stay in the tab's + memory, in one script inside Toddle's page, and are never stored. Neither is sent + anywhere but Toddle. The extension never asks for the teacher's password. +
  • +
  • + The signed-in account's email, on every + Toddle page, to check who a licence is for.
  • - A student's flags are fetched only when a staff member chooses to show them, and are - forgotten when the sidebar closes. Every query it can send is on a fixed, read-only - list; it never writes to Toddle. + Every query it can send is on a fixed, read-only list; it never writes to Toddle. On + the home page and the Attendance dashboard it adds a few fields to Toddle's own + request instead of sending a second one, and takes them out again before Toddle's + page sees the answer.

    Where it is processed

    In the browser on the teacher's own device, and nowhere else. Answers from Toddle are - held in the page's memory for a few minutes so that one view does not ask twice, then - discarded, and are gone when the tab closes. + held in the Toddle tab's memory, never written to storage, until the tab is closed or + reloaded or the browser quits. A student's or a class's details are reused for at + most 5 minutes, and a student's day for at most 2, before Toddle is asked again; a + student's flags are never reused.

    What it stores

    - The teacher's switch settings and licence key, in Chrome's storage for the extension - on that device; and a few housekeeping values in Toddle's site storage (a copy of the - switches and of which features the licence allows, whether flags are hidden, the My - classes choice, the academic year Toddle is showing, and the style of Toddle's - message button). No student data is stored. + In Chrome's storage for the extension, on that device: +

    +
      +
    • + the teacher's switches: the extension on or off, student flags, the student + sidebar, My classes, primary teacher names, gradebook tools, and student details on + the Attendance dashboard (versions before {extensionVersions.next} have three: + student flags, the student sidebar and primary teacher names); +
    • +
    • the licence key, if one was entered;
    • +
    • + the result of the last check for cancelled keys: whether the key is on the list, the + list's date and when the check was made, never the list itself; +
    • +
    • + the email address of the Toddle account last seen signed in, with the time, to check + who a licence is for. It is read on every Toddle page, with or without a licence + key, and is never sent anywhere. +
    • +
    +

    + And four small values in the browser's storage for Toddle's own site: tee-settings, + a copy of the switches and of which features the licence allows (no key and no + email); gbx-hide-flags, whether flags are hidden; tee-course-view, + whether the teacher chose My classes in the home page + filter; and tee-academic-year, the number of the academic year Toddle + last asked for. No student data is stored. A CSV export is a file the teacher chooses to save, and is then the school's to manage like any other download.

    +

    + Uninstalling the extension deletes everything in its own storage. The four values on + Toddle's site stay until the browser's data for web.toddleapp.com is + cleared. Removing a licence key deletes the key; the last check's result and the + account email stay until the extension is uninstalled. +

    What leaves the browser

    - Nothing it reads from Toddle. Its requests go to Toddle, with two outside connections, - neither carrying anything from Toddle: + Nothing it reads from Toddle, except back to Toddle. These are the only requests it + makes:

      +
    • + Toddle's own interface, with the teacher's + Toddle session: its read-only queries, carrying only ids, one date and fixed values. +
    • +
    • + Student photos, loaded from the address + Toddle gives for each one, wherever Toddle serves them, as Toddle's own page does. +
    • Feedback, only when the teacher presses Send - in the extension's menu: what they typed and the extension's version number, to - Formspree. + in the extension's menu: the topic, the message, an email address only if they typed + one, and the extension's version number, to Formspree.
    • A daily check for cancelled keys, only while @@ -163,14 +240,21 @@ const subprocessors = [ https://licences.nyuchi.dev/v1/revocations, sending no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the 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. + sees the IP address, as with any web request; Nyuchi does not log it. If the list + cannot be fetched, the last result stands.

    - Licence keys are tied to their owner: an individual key carries the buyer's email - address and an organisation key the school's email domain. The extension compares that - with the Toddle account signed in, inside the browser only, and sends it nowhere. + Anything else happens only when the teacher does it: saving a CSV file, opening an + email (mailto:) or phone (tel:) link, or sending a message in + Toddle's own chat. Like any web request, each request above lets the server that + receives it see the device's IP address and browser type. +

    +

    + A licence key carries the buyer's email address, and may also name who it is for: + the buyer's email, or for an organisation licence the school's email domain. Where it + does, the extension compares that with the Toddle account signed in, inside the + browser only, and sends it nowhere.

    What Nyuchi holds

    @@ -227,9 +311,11 @@ const subprocessors = [ Nyuchi is in {legal.country}. The services above operate internationally, and most are based in the United States, so the data they handle for us may be processed outside {legal.country} and outside the country of the school. Student data from the extension - is not part of this: it does not leave the teacher's browser. If your rules require - particular safeguards for these transfers, ask us and we will tell you what applies to - each service. + is not part of this: it does not leave the teacher's browser. {legal.country} does not + have an adequacy decision from the EU or the UK, so for data that comes to us from + there we rely on the safeguards each service provides for international transfers, + such as standard contractual clauses in its data processing terms. Ask us and we will + tell you which applies to each service.

    How long it is kept

    @@ -272,8 +358,11 @@ const subprocessors = [

    If a breach affects personal data Nyuchi holds, such as purchase or feedback data, we - will tell the customers and schools affected without undue delay and, where the law - requires it, within 72 hours of becoming aware of it. We will say what happened, what + will tell {dataProtection.home.authorityShort}, Zimbabwe's data protection authority, + within {dataProtection.home.breachHours} hours of finding it, as the + {dataProtection.home.law} requires; tell the EU or UK regulator within + {dataProtection.gdpr.breachHours} hours where the GDPR requires it; and tell the + customers and schools affected without undue delay. We will say what happened, what data was involved, what we are doing about it, and who to contact.

    diff --git a/src/pages/legal/privacy.astro b/src/pages/legal/privacy.astro index deb9c73..98ad55e 100644 --- a/src/pages/legal/privacy.astro +++ b/src/pages/legal/privacy.astro @@ -9,7 +9,13 @@ * against. They cannot drift apart without someone editing the shared source. */ import BaseLayout from "../../layouts/BaseLayout.astro"; -import { extensionDataFacts, legal, security, updatedLabel } from "../../data/legal"; +import { + dataProtection, + extensionDataFacts, + legal, + security, + updatedLabel, +} from "../../data/legal"; import { CONSENT_CATEGORIES } from "../../data/consent"; import { toddleExtension } from "../../data/site"; @@ -128,8 +134,9 @@ const description = class="mt-4 space-y-2 text-body text-muted-foreground list-disc list-outside pl-5 marker:text-foreground/40" >

  • - When. Once a day, and only while a licence - key is entered. Without a licence it never happens. + When. At most once a day, and only while a + licence key is entered; a key just entered is checked within 5 minutes. Without a + licence it never happens.
  • What is sent. One plain request to @@ -140,14 +147,15 @@ const description =
  • What comes back. A list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys. The extension checks its own - key against the list on your machine. + key against the list on your machine, and keeps only the result.
  • Who sees what. Cloudflare, which runs the server, sees your IP address, as with any web request. Nyuchi does not log it.
  • - If it fails. The extension keeps working. + If it fails. The last result stands, so a + network outage never switches a licence off.
  • @@ -157,10 +165,11 @@ const description =

    The statements above are enforced in the software and held in place by tests that fail if the extension asks for a new permission, sends anything anywhere it is not - meant to, or sends a query that is not on its fixed, read-only list. On {security.review.label} - it had an independent adversarial review, and every finding was fixed in version - {security.version}. The Security page - sets out the security model, how it is tested, what that review found, the limits + meant to, or sends a query that is not on its fixed, read-only list. It has had two + adversarial reviews. Every finding of the first, on {security.reviews[0].label}, was + fixed in version {security.reviews[0].version}; every finding of the second, on {security.reviews[1].label}, + was fixed in version {security.reviews[1].version} before it is published. + The Security page sets out the security model, how it is tested, what the reviews found, the limits no extension can remove, and how to report a vulnerability.

    @@ -240,13 +249,13 @@ const description = the cookie policy.

    - This is written to satisfy the three regimes that actually apply to our readers: - UK and EU GDPR, Zimbabwe's - Cyber and Data Protection Act [Chapter 12:07], and - Singapore's PDPA. They differ in detail and - agree on the substance, so one approach serves all three: nothing optional before you - ask for it, each purpose chosen separately, refusal as easy as agreement, withdrawal at - any time, and a record we can produce. + This is written to meet the two laws we hold ourselves to (see + the law we follow): Zimbabwe's + {dataProtection.home.law}, and the + EU and UK GDPR. They agree on the substance, + so one approach serves both: nothing optional before you ask for it, each purpose + chosen separately, refusal as easy as agreement, withdrawal at any time, and a record + we can produce.

    One detail we would rather state than have you discover. While consent is denied, @@ -300,12 +309,12 @@ const description = details.

  • - Your key contains your email. That is how - a key is tied to a person; an organisation key carries the school's email domain - instead. The extension compares it with the Toddle account signed in, inside your - browser only. The key is stored in your own browser and checked there — the - extension does not send it to us or to anyone else, not even in the daily check for - cancelled keys. + Your key contains your email. A key may also + name who it is for: your email address, or for an organisation licence the school's + email domain. Where it does, the extension compares that with the Toddle account + signed in, inside your browser only. The key is stored in your own browser and + checked there — the extension does not send it to us or to anyone else, not even in + the daily check for cancelled keys.
  • We do not market to you off the back of it. @@ -332,23 +341,27 @@ const description =

    For the personal data Nyuchi holds — licence purchases, feedback, support conversations and this website's analytics — {legal.entity} is the data controller, - including for the purposes of {legal.country}'s Cyber and Data Protection Act - [Chapter 12:07]. We rely on: + under {legal.country}'s {dataProtection.home.law} and under the EU and UK GDPR. Our + legal basis for each use is:

    • your consent for this website's analytics - and the support messenger, which you can withdraw at any time; + and the support messenger, which you can withdraw at any time (GDPR Article + 6(1)(a));
    • the contract with you to issue, deliver and - support a licence you buy, and the law for the records a business must keep; + support a licence you buy (Article 6(1)(b)), and + the law for the records a business must + keep (Article 6(1)(c));
    • your request when you send feedback or write - to us, so that we can read it and reply. + to us: our legitimate interest in reading and answering what you choose to send + (Article 6(1)(f)). You can ask us to delete it at any time.

    @@ -360,24 +373,83 @@ const description = and data processing agreements.

    -

    Your rights

    +

    + The law we follow +

    +

    + Zimbabwe's {dataProtection.home.law} applies + to us because {legal.entity} is a Zimbabwean company. Its data protection authority is + {dataProtection.home.authority}. +

    +

    + The EU and UK GDPR are the most widely used + standard for personal data, so we apply them to everyone, wherever you live, not only + to people in Europe. Where the two laws differ, we follow the stricter one. +

    +

    + Where your data goes. Nyuchi is in + {legal.country}, and the services that process data for us (listed on the + data handling page) are mostly in the United + States. So data you send us may be processed outside your country. {legal.country} + does not have an adequacy decision from the EU or the UK, so for data that comes to us + from there we rely on the safeguards each service provides for international + transfers, such as standard contractual clauses in its data processing terms. Ask us + and we will tell you which applies to each service. None of this involves student data + from the extension, which does not leave the teacher's browser. +

    + +

    + Your rights +

    - You can ask for access to the personal data we hold about you, have it corrected, - have it deleted where we are not required to keep it, and object to how we use it. + Whatever country you are in, you can ask us to: +

    +
      +
    • tell you what personal data we hold about you, and give you a copy;
    • +
    • correct it if it is wrong or incomplete;
    • +
    • delete it, unless the law requires us to keep it;
    • +
    • stop or limit how we use it while a question about it is settled;
    • +
    • give it to you, or to someone you choose, in a common machine-readable format;
    • +
    • stop using it where we rely on our legitimate interests;
    • +
    • + withdraw your consent at any time, without affecting what was done before you + withdrew it. +
    • +
    +

    + We make no decisions about you by automated means alone, and we do not profile you. Write to {legal.privacyEmail} and we will act on it. Wherever you are, we respect the rights your own law gives - you, including under the EU and UK GDPR. You can also complain to your data - protection authority. + >. There is no charge, and we answer within {dataProtection.gdpr.answerWithin}. +

    +

    + You can complain to a regulator, though we + would like the chance to put it right first. In Zimbabwe, that is + {dataProtection.home.authorityShort}. In the UK, it is + {dataProtection.gdpr.ukAuthority}. In the EU and EEA, it is the + data protection authority where you live, work, or where you think the problem happened.

    If something goes wrong

    - If a breach affects personal data we hold, we will tell the people and schools - affected without undue delay, and within 72 hours where the law requires it. The + If a breach affects personal data we hold, we will tell + {dataProtection.home.authorityShort} within {dataProtection.home.breachHours} hours of + finding it, as Zimbabwe's Act requires; tell the EU or UK regulator within + {dataProtection.gdpr.breachHours} hours where the GDPR requires it; and tell the + people and schools affected without undue delay. The data handling page says what we will tell you. The extension holds no student data, so a breach on our side cannot expose student data from Toddle. diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index 1d6fe99..7336728 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -8,17 +8,26 @@ * be linked to; what they say is set out here instead, and the full reporting * policy is at /legal/vulnerability-disclosure. * - * Every statement is about the build named in `security.version` - * (src/data/legal.ts). When the extension's security model changes, this page - * changes with it, and the date at the top moves. + * Every statement is about the published build named in `security.version` + * and the reviewed next one in `security.next` (src/data/legal.ts); where they + * differ, the page says which. When the extension's security model changes, + * this page changes with it, and the date at the top moves. */ import BaseLayout from "../../layouts/BaseLayout.astro"; -import { legal, security, securityMailto, updatedLabel } from "../../data/legal"; +import { + extensionVersions, + legal, + security, + securityMailto, + updatedLabel, +} from "../../data/legal"; import { toddleExtension } from "../../data/site"; const title = "Security — Nyuchi Learning"; const description = - "How the Toddle Enhancement Extension is built and tested to keep student data inside Toddle: its permissions, its closed loop, the independent adversarial review of 1 October 2026 and what it fixed, its honest limits, and how to report a vulnerability."; + "How the Toddle Enhancement Extension is built and tested to keep student data inside Toddle: its permissions, its closed loop, the adversarial reviews of 1 and 6 October 2026 and what they fixed, its honest limits, and how to report a vulnerability."; + +const [firstReview, secondReview] = security.reviews; /* The summary box. A busy IT lead should be able to stop reading after this. */ const glance = [ @@ -36,11 +45,12 @@ const glance = [ { label: "Sends", value: - "Nothing it reads, to anyone but Toddle. Two outside connections, neither carrying Toddle data: feedback, when a person presses Send; and, while a licence is entered, a daily download of a signed list of cancelled keys.", + "Nothing it reads, to anyone but Toddle. Besides Toddle itself (its own interface, and student photos from wherever Toddle serves them), two outside connections, neither carrying Toddle data: feedback, when a person presses Send; and, while a licence is entered, a daily download of a signed list of cancelled keys.", }, { label: "Stores", - value: "Settings and a licence key, on the device. No student data.", + value: + "Settings, a licence key, the last licence check's result and the email of the Toddle account last seen, on the device. No student data.", }, { label: "Tested", @@ -49,19 +59,21 @@ const glance = [ }, { label: "Reviewed", - value: `Independent adversarial review, ${security.review.label}. Every finding fixed in ${security.version}.`, + value: `Two adversarial reviews: ${firstReview.label}, every finding fixed in ${firstReview.version}; and ${secondReview.label}, every finding fixed in ${secondReview.version} before it is published.`, }, ]; const sections = [ { id: "model", label: "The security model" }, { id: "testing", label: "How it is tested" }, - { id: "review", label: "Independent review" }, + { id: "review", label: "Adversarial reviews" }, { id: "limits", label: "What no extension can promise" }, { id: "report", label: "Reporting a vulnerability" }, ]; -/* From the extension's docs/security-review.md, in plain words. */ +/* From the extension's docs/security-review.md, in plain words. The review of + 1 October 2026, findings 1 to 12 (12 is a verification note, not a + vulnerability, and is left out here). */ const findings = [ { severity: "High", @@ -127,6 +139,40 @@ const findings = [ }, ]; +/* The review of 6 October 2026, findings 13 to 16, on the code for 0.8.4. */ +const laterFindings = [ + { + severity: "Medium", + finding: + "The rebuilt flags code handed over its secret on request. A script in the page could ask it for its state, and the per-load secret came back with the answer.", + fix: "It no longer answers requests. The page half always starts first and is told of every change.", + }, + { + severity: "Medium", + finding: + "The key that signs releases could reach third-party build tools. The tools that build the extension's own pages ran with the release job's settings, the signing key among them.", + fix: "The page build gets only a short list of ordinary settings. No secret is among them, and one added later would not reach it either.", + }, + { + severity: "Low", + finding: + "An element on Toddle's page could stop the rebuilt features from starting, by taking a name the extension uses to make sure it starts only once.", + fix: "Each of those checks now looks for exactly the value the extension sets, which an element on the page cannot be.", + }, + { + severity: "Low", + finding: + "What ships was not scanned. The tests read the source code, but not the built files that ship, with React and the other packages in them.", + fix: "A test now reads the built files: no eval, no cookie or other side channel, one network call in all of it (the feedback form, never on Toddle's pages), only known addresses, closed shadow roots and no inline script.", + }, + { + severity: "Info", + finding: + "An unused package, a wrong comment in the test runner, and wording in the extension's own documents that claimed more than the code inside Toddle's page can hold.", + fix: "Removed and corrected. The limits are set out below.", + }, +]; + const severityClass: Record = { High: "badge badge-warning", Medium: "badge badge-accent", @@ -142,9 +188,11 @@ const severityClass: Record = {

    Security

    Last updated {updatedLabel}. Describes the - {toddleExtension.name}, version - {security.version}. For schools, and for the privacy, legal and IT colleagues who - review software on their behalf. + {toddleExtension.name}: version {security.version}, + the one on the Chrome Web Store ({extensionVersions.sameCodeAs} carries the same code), + and version {security.next}, reviewed and not yet published. + Where the two differ, this page says so. For schools, and for the privacy, legal and + IT colleagues who review software on their behalf.

    @@ -224,12 +272,16 @@ const severityClass: Record = { exactly, with variables of the expected shape, and sends only to Toddle's own API, checked again immediately before each request. None of the queries writes, and any write is refused, so the extension cannot change a gradebook, a record or a setting - even by accident. + even by accident. On the home page and the Attendance dashboard it adds a few fields + to Toddle's own request instead of sending a second one, and takes them out again + before Toddle's page sees the answer.

    It reuses the session the teacher is already signed in with, so Toddle answers - with what that teacher's account may already see, and no more. It never sees a - password. Its message buttons open Toddle's own chat window, on real clicks only; + with what that teacher's account may already see, and no more. It never asks for a + password. It notes the sign-in headers from Toddle's own requests, in the tab's + memory, in one script inside Toddle's page, and sends them nowhere but Toddle; they + are never stored. Its message buttons open Toddle's own chat window, on real clicks only; the extension sends no message itself, and Toddle's chat applies the school's messaging rules.

    @@ -240,8 +292,9 @@ const severityClass: Record = {

    There is no account, no analytics, no telemetry, no tracking, no advertising and no remote code. What the extension reads from Toddle goes back to Toddle's own screen - and nowhere else. It makes two connections outside Toddle, and neither carries - anything from Toddle: + and nowhere else. Besides Toddle itself (its own interface, with the teacher's + session, and student photos from the address Toddle gives for each one), it makes + two connections, and neither carries anything from Toddle:

      = { privacy policy.
    • - A daily check for cancelled keys. Once a - day, only while a licence is entered, the background worker makes one plain request + A daily check for cancelled keys. At most + once a day, only while a licence is entered, the background worker makes one plain request to https://licences.nyuchi.dev/v1/revocations. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, and checks its own key against it locally. Cloudflare sees the IP address, as with any - web request; Nyuchi does not log it. If the check fails, the extension keeps - working. + web request; Nyuchi does not log it. If the list cannot be fetched, the last result + stands, so a network outage never switches a licence off.
    +

    + Anything else happens only when the teacher does it: saving a CSV file, opening an + email or phone link, or sending a message in Toddle's own chat. +

    Licence keys are checked on the device

    A licence key is a signed statement that the extension verifies on the device, - against a public key it carries. Licences are tied to their owner: an individual key - carries the buyer's email address, and an organisation key the school's email domain. - The extension compares that with the Toddle account signed in, inside the browser - only. Activating a key contacts nothing, and the key is never sent anywhere. It is - kept in the extension's own storage, which Toddle's page cannot reach. + against a public key it carries. A key carries the buyer's email address, and may + also name who it is for: the buyer's email, or for an organisation licence the + school's email domain. Where it does, the extension compares that with the Toddle + account signed in, inside the browser only, and sends it nowhere. A key that names no one + works for any account until it expires. Activating a key contacts nothing, and the + key is never sent anywhere. It is kept in the extension's own storage, which Toddle's + page cannot reach.

    No student data on the device

    - The extension stores the teacher's switch settings and licence key, plus a few - housekeeping values in Toddle's own site storage (a copy of the switches, the - academic year Toddle is showing, and a style name). No student data is stored - anywhere. What it reads about students, classes and timetables is held in memory for - at most a few minutes and is gone when the tab closes. Student flags are hidden by - default; the sidebar fetches a student's flags only when someone chooses to show - them, and forgets them when it closes. The CSV export is made in the browser and saved - where the teacher's downloads go, by the teacher's choice. + In the extension's own storage: the teacher's switches, the licence key if one was + entered, 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. In Toddle's own + site storage, four housekeeping values: a copy of the switches and of which features + the licence allows, whether flags are hidden, the My classes choice, and the academic + year Toddle is showing. No student data is stored anywhere. What it reads about + students, classes and timetables is held in the Toddle tab's memory until the tab is + closed or reloaded; a student's or a class's details are reused for at most 5 + minutes, and a student's day for at most 2, before Toddle is asked again. +

    +

    + From version {security.next}, student flags are shown as Toddle shows them, and a + staff member can hide them everywhere with the free flag switch; versions + before {security.next} hid them by default. Unless flags are hidden, the sidebar asks Toddle + for a student's flags each time it opens, and keeps them only while it is open. The + CSV export is made in the browser and saved where the teacher's downloads go, by the + teacher's choice.

    Protections inside the page

    @@ -298,13 +366,14 @@ const severityClass: Record = { class="mt-4 space-y-2 text-body text-muted-foreground list-disc list-outside pl-5 marker:text-foreground/40" >
  • - The sign-in token stays in Toddle's page. The part of the extension that draws its - interface cannot read it. + Toddle's sign-in headers stay in one script in Toddle's page. The extension's other + parts never see them.
  • Its public objects are frozen, so a page script cannot rewrite them.
  • Answers to its queries travel on a private channel, never broadcast, so no other - script can read them or answer in their place. + script can read them. A script already listening in the page could still answer in + their place (see the limits).
  • The query gate uses JavaScript built-ins captured before any page script runs, so a @@ -313,7 +382,8 @@ const severityClass: Record = {
  • Messages that change what is shown, such as licensed features or student flags, carry a secret shared only between the extension's own parts, new on every page - load. + load. It stops scripts that are not listening for it; a script already listening + can read it (see the limits).
  • Text read from Toddle is shown as text and never interpreted as markup, and values @@ -369,24 +439,28 @@ const severityClass: Record = {

    - Independent review, {security.review.label} + Adversarial reviews

    - On {security.review.label} the extension had an independent adversarial review, with - proofs of concept run against the real extension. It considered a malicious script - in Toddle's page, a malicious website in another tab, another extension, and crafted - responses from Toddle. It found two high-severity issues, one medium and several low. - All of them were fixed in version {security.version}, and each fix carries a - regression test. + The extension has had two adversarial reviews, with proofs of concept run against + the real extension. They considered a malicious script in Toddle's page, a malicious + website in another tab, another extension, and crafted responses from Toddle. Every + finding was fixed, and each fix carries a regression test.

    +

    {firstReview.label}

    +

    + The first review, of the code that became version {firstReview.version} (findings {firstReview.findings}), + found two high-severity issues, one medium and several low. + All of them were fixed in version {firstReview.version}. +

    { findings.map((item) => (

    {item.severity} - Fixed in {security.version} + Fixed in {firstReview.version}

    {item.finding}

    @@ -399,13 +473,47 @@ const severityClass: Record = {

    The issues were present in released versions from 0.5.0 to 0.8.1, depending on the finding. Only {security.version} and later are supportedOnly {security.supportedFrom} and later are supported; Chrome updates installed copies from the Chrome Web Store on its own, and a school that pins a version should move to the current one. The review also attempted, and found held: running a query outside the list, or a write; making the extension send to any host but Toddle's; reading the licence key from the page; reaching the feedback form from Toddle's page; and opening an extension page from a website.

    + +

    {secondReview.label}

    +

    + The second review covered everything new in version {secondReview.version}: the + extension's own pages, rebuilt; the first rebuilt part that runs on Toddle's pages + (the flags switch, in a closed shadow root that no script on the page can reach); the + build that makes them; and the new switches. It also re-read the code that runs in + Toddle's page against the claims made about it. It found two medium-severity issues + and two low (findings {secondReview.findings}). All were fixed in + version {secondReview.version} before it is published, and none was in + version {security.version}. +

    +
    + { + laterFindings.map((item) => ( +
    +

    + {item.severity} + Fixed in {secondReview.version} +

    +

    {item.finding}

    +

    + Fix. {item.fix} +

    +
    + )) + } +
    +

    + It also checked, and found sound: the package that ships, the permissions it asks for + (storage and Toddle only, nothing reachable from websites), the built pages, the + network (the same two outside requests as before), and the list of packages the build + uses. +

    We publish what was found, not only that it was fixed, because a school deciding whether to trust software should know. @@ -437,6 +545,42 @@ const severityClass: Record = { student's profile.

    +

    + What the code inside Toddle's page cannot promise +

    +

    + Some of the extension has to run in Toddle's own page, because that is the only place + Toddle's data can be read, and any script on the page shares that space. + The review of {secondReview.label} set out, plainly, what that means. A script that is already + listening in Toddle's page when the extension starts: +

    +
      +
    • + can read the per-load secrets, which stop only scripts that are not listening, and + replay them in that browser: switch a licensed feature on, give the licence check + another account's email, or show flags that were hidden. It gains no student data by + it, because it already runs with the teacher's session and could read Toddle + directly; +
    • +
    • + cannot overhear the answers to the extension's queries, but can answer first with + false ones, so the gradebook could show wrong figures. Values in the CSV export + still cannot start a spreadsheet formula; +
    • +
    • + is stopped from pressing the extension's message buttons, but can tell Toddle's own + app to open a chat in any case. +
    • +
    +

    + None of this lets anything leave the browser, write to Toddle, or reach the + extension's own pages, licence key or storage. The fix is the rebuild under way, which is + moving every decision and every piece of the interface out of Toddle's page into the + extension's own, leaving there only what has to read the page. +

    +

    Reporting a vulnerability diff --git a/src/pages/legal/student-privacy.astro b/src/pages/legal/student-privacy.astro index 17c7bd1..c12d853 100644 --- a/src/pages/legal/student-privacy.astro +++ b/src/pages/legal/student-privacy.astro @@ -7,7 +7,7 @@ * enforced by the extension's closed-loop tests. */ import BaseLayout from "../../layouts/BaseLayout.astro"; -import { legal, updatedLabel } from "../../data/legal"; +import { dataProtection, legal, updatedLabel } from "../../data/legal"; import { toddleExtension } from "../../data/site"; const title = "Student privacy — Nyuchi Learning"; @@ -53,12 +53,13 @@ const privacyMail = `mailto:${legal.privacyEmail}`;

  • It is processed in that teacher's browser, on that device, and nowhere else.
  • - It is not stored: answers are held in the page's memory for a few minutes and are gone - when the tab closes. + It is not stored: answers are held in the Toddle tab's memory until the tab is closed + or reloaded, and never written to storage.
  • - It is not transmitted to Nyuchi or to anyone else. The extension's only connections - outside Toddle are the opt-in feedback form, which carries only what the teacher + It is not transmitted to Nyuchi or to anyone else. Apart from Toddle itself (its own + interface, and student photos from wherever Toddle serves them), the extension's + only connections are the opt-in feedback form, which carries only what the teacher types, and, while a licence is entered, a daily check for cancelled keys, which sends no data.
  • @@ -82,9 +83,13 @@ const privacyMail = `mailto:${legal.privacyEmail}`; by children, and the student information it shows to staff is not collected by Nyuchi.

    -

    GDPR, UK GDPR and similar laws

    +

    + GDPR, UK GDPR, Zimbabwe's Act and similar laws +

    - The school remains the controller of its student data, and Toddle its processor. + Nyuchi follows Zimbabwe's {dataProtection.home.law} and the EU and UK GDPR (see + the law we follow). Under all of them, + the school remains the controller of its student data, and Toddle its processor. Nyuchi receives none of that data from the extension. See data handling for what Nyuchi does hold, about purchasers and people who send feedback. diff --git a/src/pages/legal/terms.astro b/src/pages/legal/terms.astro index d640610..3a6aa82 100644 --- a/src/pages/legal/terms.astro +++ b/src/pages/legal/terms.astro @@ -61,7 +61,8 @@ const description = Hiding student flags is free and is not licensed. It protects a child, so it is not something we will ever put behind a payment. The My classes filter on the Toddle home page is free too. A licence covers the gradebook's per-criterion columns, the - CSV export, the student sidebar and primary teacher names. + CSV export, the student sidebar, primary teacher names and the Attendance + dashboard's student details.

    diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index dc04194..9bc2aa9 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -197,7 +197,7 @@ const facts = [ { label: "Its two outside connections", value: - "The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licences.nyuchi.dev for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.", + "The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licenses.nyuchi.com for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.", }, { label: "What it stores", @@ -509,7 +509,8 @@ const schema = [ it to staff; each teacher pastes it in once, from the welcome page or the toolbar menu's Enter a licence key. If your network filters outbound traffic, allow {toddleExtension.licenceHost} - so the daily check for cancelled keys can run. If it is blocked, licences keep + (and licences.nyuchi.dev, which earlier versions + ask) so the daily check for cancelled keys can run. If it is blocked, licences keep working on the last result.

    From fe87b4516b381f7ab288eab96039db35450b1b1b Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 11:50:34 +0800 Subject: [PATCH 07/20] docs: one account of 0.9.0 across the site (#70) * docs: one account of 0.9.0 across the site (review of the release) - The extension page and the Security page say the gradebook's toolbar and the Attendance dashboard's details run in closed shadow roots from 0.9.0, as the releases page and the extension's code (rebuild steps 5 and 7) do; they no longer say these are still moving. - The second review's fixes are in 0.8.4, a test build, and reach schools in 0.9.0, not '0.8.4 before it is published'. - Concealing flags: a new install starts with flags shown; updating from 0.8.2 keeps them concealed. Not 'off until you switch it on'. - llms.txt: the sidebar asks for a student's active flags each time it opens, as legal.ts says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett * docs: re-review fixes: 0.9.0 is still to come; llms.txt matches the pages Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq Signed-off-by: Bryan Fawcett * docs: re-review fixes: the 0.8.4 fixes reach schools in 0.9.0; every reviewed rebuild step named; llms.txt flags say what 0.8.2 does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: the extension page's flags paragraph says from which version Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Signed-off-by: Bryan Fawcett Co-authored-by: Claude Opus 5.5 --- SECURITY.md | 4 ++-- public/llms.txt | 13 ++++++++----- src/pages/legal/security.astro | 13 +++++++------ src/pages/toddle-enhancement-extension.astro | 10 ++++++---- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index b6ed572..ff65db2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ 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 adversarial -reviews of 1 October 2026 (0.8.2) and 6 October 2026 (0.8.4, before it is -published), with their findings and the limits of code that runs in Toddle's +reviews of 1 October 2026 (0.8.2) and 6 October 2026 (0.8.4, a test build; +its fixes reach schools in 0.9.0), 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 diff --git a/public/llms.txt b/public/llms.txt index cb13946..57985b7 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -44,8 +44,9 @@ community-based platforms. Nyuchi Web Services is its development division. 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. From version 0.9.0 the flag switch, - the student sidebar and the home page's additions run in the extension's - own isolated world, drawn in closed shadow roots that no script on + 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 @@ -56,7 +57,7 @@ community-based platforms. Nyuchi Web Services is its development division. 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 when they are shown. It notes the sign-in headers and + 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 @@ -67,8 +68,10 @@ community-based platforms. Nyuchi Web Services is its development division. 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 flags each time it opens, - and keeps them only while it is open. Apart + covered. From version 0.9.0 the sidebar asks Toddle for a student's + active flags each time it opens, and keeps them only while it is open; in + version 0.8.2, while flags are hidden, it asks only when someone presses + its eye button. 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 diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index f77fde4..6685ce4 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -60,7 +60,7 @@ const glance = [ }, { label: "Reviewed", - value: `Two adversarial reviews: ${firstReview.label}, every finding fixed in ${firstReview.version}; and ${secondReview.label}, every finding fixed in ${secondReview.version} before it is published.`, + value: `Two adversarial reviews: ${firstReview.label}, every finding fixed in ${firstReview.version}; and ${secondReview.label}, every finding fixed in ${secondReview.version}, a test build, whose fixes reach schools in 0.9.0.`, }, ]; @@ -365,15 +365,15 @@ const severityClass: Record = {

    The extension is being rebuilt so that as little of it as possible runs where Toddle's own scripts run. From version {security.next}, the flag switch, the student - sidebar with a class's teachers, and the home page's My classes option and teacher - names run in the extension's own isolated part of the browser. What they draw on + sidebar with a class's teachers, the home page's My classes option and teacher + names, the gradebook's toolbar and controls, and the Attendance dashboard's details + run in the extension's own isolated part of the browser. What they draw on Toddle's pages sits inside closed shadow roots, which no script on Toddle's page can read or click into, and whether each one runs is decided by the extension's own switches and licence, never by Toddle's page. What only Toddle's page holds (who was clicked, the course list as it loads, Toddle's chat and icons) is read by small parts with no interface of their own, which talk to the extension on private channels set - up before Toddle's own scripts run. The gradebook and the Attendance dashboard are - moving the same way. + up before Toddle's own scripts run.

    Protections inside the page

    @@ -511,7 +511,8 @@ const severityClass: Record = { medium-severity issues and two low (findings {secondReview.findings}). All were fixed in version {secondReview.version}, and the fixes are in every release after it, including {security.next}. None was in version {security.version}. Each later - step of the rebuild (the student sidebar, the home page) was reviewed the same way as + step of the rebuild (the student sidebar, the home page, the gradebook's toolbar and + the Attendance dashboard's details) was reviewed the same way as it was made, and what those reviews found about the page's limits is set out below.

    diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index 9bc2aa9..774314b 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -177,7 +177,7 @@ const facts = [ { label: "Where it runs", value: - "Only on web.toddleapp.com. It has no access to any other site. From version 0.9.0 the flag switch, the student sidebar and the home page's additions run in the extension's own isolated part of the browser, drawn inside closed shadow roots that no script on Toddle's page can read or click into; the gradebook and the Attendance dashboard are moving the same way.", + "Only on web.toddleapp.com. It has no access to any other site. From version 0.9.0 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 part of the browser, drawn inside closed shadow roots that no script on Toddle's page can read or click into.", }, { label: "What it reads", @@ -380,9 +380,11 @@ const schema = [ room should not be reading over your shoulder.

    - The extension no longer hides Toddle's own flags: they stay where Toddle puts - them, with their links and documents in Toddle's student popover. Concealing - them is your choice, off until you switch it on. {flags.reveal} + From version {extensionVersions.next}, the extension no longer hides Toddle's own + flags: they stay where Toddle puts them, with their links and documents in Toddle's student popover. Concealing + them is your choice: a new install starts with flags shown, and if you are + updating from 0.8.2, where flags were hidden by default, or you had hidden them + yourself, they stay concealed until you show them. {flags.reveal}

    From 2dbfdbae94d68ac6df04b8cbdfb1c53e7d9836fb Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 12:04:14 +0800 Subject: [PATCH 08/20] docs: 0.9.0's last changes: readable sizes, flag Markdown, a licence that follows your Chrome account, and the upgrade (#72) * wip * wip2 * docs: an older unpacked copy's licence is entered once more Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Co-authored-by: Claude Opus 5.5 --- public/llms.txt | 13 +++++-- src/data/legal.ts | 40 +++++++++++++++++--- src/data/releases.ts | 22 +++++++++-- src/pages/legal/cookies.astro | 6 +-- src/pages/legal/data.astro | 23 ++++++++--- src/pages/legal/privacy.astro | 3 +- src/pages/legal/security.astro | 9 +++-- src/pages/legal/terms.astro | 3 +- src/pages/toddle-enhancement-extension.astro | 11 +++++- tests/seo.test.ts | 12 +++++- 10 files changed, 112 insertions(+), 30 deletions(-) diff --git a/public/llms.txt b/public/llms.txt index 57985b7..3af4cb3 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -30,7 +30,9 @@ community-based platforms. Nyuchi Web Services is its development division. 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. The sidebar works anywhere a + dashboard's details, and the student sidebar. From 0.9.0 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 @@ -90,7 +92,11 @@ community-based platforms. Nyuchi Web Services is its development division. 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); and, in Toddle's own site storage, a copy of the + who a licence is for). From version 0.9.0 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 @@ -172,7 +178,8 @@ 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 from 0.9.0 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), two adversarial reviews (1 October 2026: two High, diff --git a/src/data/legal.ts b/src/data/legal.ts index ceea1e1..bdf278e 100644 --- a/src/data/legal.ts +++ b/src/data/legal.ts @@ -134,6 +134,36 @@ export const extensionVersions = { const { current, next } = extensionVersions; +/** + * The licence key and Chrome sync, said once. From 0.9.0 the key alone is + * also kept in chrome.storage.sync, so it follows the teacher's own Chrome + * account. That is Chrome's sync, not Nyuchi's: the extension never sends the + * key to Nyuchi. Checked against the extension's SECURITY.md and + * docs/data-schema.md (the `licence` key). + */ +export const licenceSync = { + /** One sentence, for summaries. */ + short: `From version ${next}, your licence key also follows your own Chrome account to your other computers, through Chrome's sync; it is never sent to Nyuchi.`, + /** The full account, for the data pages. */ + detail: `From version ${next}, the licence key, and nothing else, is also kept in Chrome's synced storage for the extension, so Chrome carries it to the other computers signed in to the same Chrome account, unless sync, or a school's Chrome policy, turns that off. That is Chrome's own sync, through your Google account; the extension never sends the key to Nyuchi. A key carries the buyer's email address, so that travels with it. The newest key wins on every computer, and removing it on one removes it on all of them. Who a key is for, and the result of the check for cancelled keys, stay on each computer.`, + /** For "what leaves the device". */ + leaves: `One thing more leaves your device from version ${next}, and not to us: with Chrome sync on, Chrome carries your licence key to your other computers through your own Chrome account. The extension itself never sends the key anywhere, Nyuchi included.`, +} as const; + +/** + * The upgrade from an individual licence to the school's, said once: the + * product page and llms.txt. The code is shown in the shipped extension + * (0.9.0) to individual licence holders, and works on the organisation + * licence only. + */ +const upgradeCode = "BXXF20VA"; +const upgradeOff = "US$7"; +export const upgrade = { + code: upgradeCode, + off: upgradeOff, + text: `Already have an individual licence? Bring your whole school in. From version ${next} the extension's licence page shows individual licence holders the upgrade, with the code ${upgradeCode}, which takes ${upgradeOff} off the organisation licence (it works on that licence only). Your school's key then replaces yours on every computer.`, +} as const; + /** * Student flags, said once. From 0.9.0 the extension never hides Toddle's own * flags: a teacher can conceal them for a shared screen. The wording is @@ -187,8 +217,7 @@ export const extensionDataFacts = [ }, { claim: "Exactly what leaves your device.", - detail: - "Nothing it reads from Toddle goes anywhere but back to Toddle. There is no account, no analytics, no telemetry, no tracking, no advertising and no remote code. The extension makes these requests and no others: read-only requests to Toddle's own interface, with your Toddle session; student photos, loaded from the address Toddle gives for each one, wherever Toddle serves them, as Toddle's own page does; the feedback form, only when you press Send; and, only while a licence key is entered, the daily check for cancelled keys, which carries nothing. The last two are described next. Anything else happens only when you do it: saving a CSV file, opening an email or phone link, or sending a message in Toddle's own chat. Like any web request, each one lets the server that receives it see your IP address and browser type.", + detail: `Nothing it reads from Toddle goes anywhere but back to Toddle. There is no account, no analytics, no telemetry, no tracking, no advertising and no remote code. The extension makes these requests and no others: read-only requests to Toddle's own interface, with your Toddle session; student photos, loaded from the address Toddle gives for each one, wherever Toddle serves them, as Toddle's own page does; the feedback form, only when you press Send; and, only while a licence key is entered, the daily check for cancelled keys, which carries nothing. The last two are described next. Anything else happens only when you do it: saving a CSV file, opening an email or phone link, or sending a message in Toddle's own chat. Like any web request, each one lets the server that receives it see your IP address and browser type. ${licenceSync.leaves}`, }, { claim: "Outside connection one, only when you choose it: feedback.", @@ -202,7 +231,7 @@ export const extensionDataFacts = [ }, { claim: "What it stores: settings and licence details, and no student data.", - detail: `In Chrome's storage for the extension, on your device: your switches (the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on the Attendance dashboard); your licence key, if you entered one; the result of the last check for cancelled keys (whether your key is on the list, the list's date and when the check was made, never the list itself); and the email address of the Toddle account last seen signed in, with the time, so the extension can check who a licence is for. That email is read on every Toddle page, with or without a licence key, and stays on the device. In the browser's storage for Toddle's own site: tee-settings, a copy of your switches and of which features the licence allows (no key and no email); tee-blur-flags (gbx-hide-flags in version ${current}), whether flags are concealed, so they are concealed before the page is drawn; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. Nothing it reads about students, classes, results or attendance is ever written to storage. Version ${current} has three switches: student flags, the student sidebar and primary teacher names.`, + detail: `In Chrome's storage for the extension, on your device: your switches (the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on the Attendance dashboard); your licence key, if you entered one; the result of the last check for cancelled keys (whether your key is on the list, the list's date and when the check was made, never the list itself); and the email address of the Toddle account last seen signed in, with the time, so the extension can check who a licence is for. That email is read on every Toddle page, with or without a licence key, and stays on the device. ${licenceSync.detail} In the browser's storage for Toddle's own site: tee-settings, a copy of your switches and of which features the licence allows (no key and no email); tee-blur-flags (gbx-hide-flags in version ${current}), whether flags are concealed, so they are concealed before the page is drawn; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. Nothing it reads about students, classes, results or attendance is ever written to storage. Version ${current} has three switches: student flags, the student sidebar and primary teacher names.`, }, { claim: "What it holds in memory, and for how long.", @@ -212,7 +241,7 @@ export const extensionDataFacts = [ { claim: "Your licence is checked on your machine, not by asking us.", detail: - "A licence key is a signed statement that the extension verifies locally. It carries the buyer's email address. A key may also name who it is for: the buyer's email, or for an organisation licence the school's email domain. Where it does, the extension compares that with the Toddle account signed in, inside the browser only, and sends it nowhere. Activating a key contacts nothing, which is why it works without a connection.", + "A licence key is a signed statement that the extension verifies locally. It carries the buyer's email address. A key may also name who it is for: the buyer's email, or for an organisation licence the school's email domain. Where it does, the extension compares that with the Toddle account signed in, inside the browser only, and sends it nowhere. Activating a key contacts nothing, which is why it works without a connection, and the extension never sends the key to Nyuchi.", }, { claim: @@ -227,8 +256,7 @@ export const extensionDataFacts = [ }, { claim: "Removing it removes what it stored.", - detail: - "Uninstalling the extension deletes everything in its own storage. The four small values on Toddle's site stay until the browser's data for web.toddleapp.com is cleared; none of them is about a student. Removing a licence key deletes the key; the last check's result and the Toddle account's email stay until the extension is uninstalled.", + detail: `Uninstalling the extension deletes everything in its own storage on that computer. The four small values on Toddle's site stay until the browser's data for web.toddleapp.com is cleared; none of them is about a student. Removing a licence key deletes the key (from version ${next}, on every computer signed in to the same Chrome account, so remove the licence before uninstalling if you want it gone from your Chrome account too); the last check's result and the Toddle account's email stay until the extension is uninstalled.`, }, ] as const; diff --git a/src/data/releases.ts b/src/data/releases.ts index deb6958..4c34350 100644 --- a/src/data/releases.ts +++ b/src/data/releases.ts @@ -68,7 +68,7 @@ export const releases: Release[] = [ version: "0.9.0", status: "coming", summary: - "Everything since 0.8.2, in one release: student flags you control with one switch, a sidebar that tells you more about the people around a student, and the extension rebuilt so that Toddle's own page can't reach into it.", + "Everything since 0.8.2, in one release: student flags you control with one switch, a sidebar that tells you more about the people around a student, a licence that follows your Chrome account, and the extension rebuilt so that Toddle's own page can't reach into it.", sections: [ { heading: "Student flags: shown, or concealed", @@ -96,16 +96,32 @@ export const releases: Release[] = [ items: [ "My classes switches on and off at once, and stays right at schools with long course lists.", "The gradebook tools and the Attendance dashboard's details are rebuilt, and look and work as before. Grade scale colours from your school's Toddle now show in the expanded columns.", - "The Attendance dashboard's Excusals tab gets the same details as the Students tab: each student's year group and, today, the class they are in right now with its primary teacher.", + "The Attendance dashboard's Excusals tab gets the same details as the Students tab: each student's year group and, today, the class they are in right now with its primary teacher. A new “Time out” column, its header lined up over its values, says how long each excusal keeps a student out: “1 day”, the school days, or the real times from the day's timetable.", "A switch for every feature in the toolbar menu, grouped by where it works, with a master switch that turns everything off.", ], }, + { + heading: "Easier to read", + items: [ + "Text and icons are the right size again. Toddle sets its page's base font size to 10px, which shrank everything the extension drew on Toddle to 62.5% of its size: 7.5px text and 10px icons. They now match Toddle's own text.", + "A flag's text keeps the shape it was written in: paragraphs, line breaks, headings, lists, bold, italic, code and links (https only), instead of running together as one long paragraph. It is built as page elements, never inserted as HTML.", + ], + }, + { + heading: "Your licence, and buying one", + items: [ + "Your licence follows your Chrome account. Enter a key once and Chrome's own sync carries it to every computer signed in to the same Chrome account. The newest key wins, and removing it on one computer removes it on all of them. The key is never sent to Nyuchi.", + "Both plans are in the extension: the welcome page and the licence page show Individual and Organisation, each with its own checkout, and your key arrives by email. “See plans” in the toolbar menu opens them.", + "If you have an individual licence, the extension offers the upgrade to your school's licence instead, with a code that takes money off the organisation licence. Your school's key then replaces yours on every computer.", + "For IT teams who load the extension unpacked: each release's unpacked copy now carries the Chrome Web Store's extension ID, so a synced licence reaches it too. The first time you load it, it replaces an older unpacked copy's ID, so a licence entered there needs entering once more.", + ], + }, { heading: "Built to keep Toddle's page out", items: [ "The flags switch, the student sidebar, My classes and teacher names, the gradebook's toolbar and controls, and the Attendance dashboard's details now run in the extension's own part of the browser, inside closed shadow roots that no script on Toddle's page can read or click into. Whether each one runs is decided by your switches and licence, never by Toddle's page.", "Each rebuilt part was given its own security review on 6 October 2026, as were flag visibility and the sidebar's new staff and class views. Everything they found is fixed, each with a test. The Security page has the details.", - "Nothing new is read from Toddle and nothing new is sent anywhere.", + "Nothing new is read from Toddle, and the extension sends nothing new anywhere. The one change is Chrome's: it now syncs your licence key through your own Chrome account, never to Nyuchi.", ], }, ], diff --git a/src/pages/legal/cookies.astro b/src/pages/legal/cookies.astro index f642e74..e1f4bba 100644 --- a/src/pages/legal/cookies.astro +++ b/src/pages/legal/cookies.astro @@ -11,7 +11,7 @@ * document for their own products; they set them, and they can change them. */ import BaseLayout from "../../layouts/BaseLayout.astro"; -import { extensionVersions, legal, updatedLabel } from "../../data/legal"; +import { extensionVersions, legal, licenceSync, updatedLabel } from "../../data/legal"; import { CONSENT_KEY } from "../../data/consent"; import { analytics, toddleExtension } from "../../data/site"; @@ -205,8 +205,8 @@ const rows = [ tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle is showing. None of it is about a student, and the extension sends none of it - anywhere. - Uninstalling the extension deletes its own storage; the four values on Toddle's site + anywhere. {licenceSync.short} + Uninstalling the extension deletes its own storage on that computer; the four values on Toddle's site stay until you clear the browser's data for Toddle's site. The data handling and security pages have the detail. diff --git a/src/pages/legal/data.astro b/src/pages/legal/data.astro index 5ea4f42..8c78b7f 100644 --- a/src/pages/legal/data.astro +++ b/src/pages/legal/data.astro @@ -14,6 +14,7 @@ import { extensionVersions, flags, legal, + licenceSync, refunds, updatedLabel, } from "../../data/legal"; @@ -185,7 +186,10 @@ const subprocessors = [ the Attendance dashboard (version {extensionVersions.current} has three: student flags, the student sidebar and primary teacher names); -
  • the licence key, if one was entered;
  • +
  • + the licence key, if one was entered (from version {extensionVersions.next} also kept + in Chrome's synced storage; see below); +
  • the result of the last check for cancelled keys: whether the key is on the list, the list's date and when the check was made, never the list itself; @@ -207,10 +211,16 @@ const subprocessors = [ like any other download.

    - Uninstalling the extension deletes everything in its own storage. The four values on - Toddle's site stay until the browser's data for web.toddleapp.com is - cleared. Removing a licence key deletes the key; the last check's result and the - account email stay until the extension is uninstalled. + {licenceSync.detail} +

    +

    + Uninstalling the extension deletes everything in its own storage on that computer. + The four values on Toddle's site stay until the browser's data for + web.toddleapp.com is cleared. Removing a licence key deletes the key (from + version {extensionVersions.next}, on every computer signed in to the same Chrome + account, so remove it before uninstalling if you want it gone from your Chrome account + too); the last check's result and the account email stay until the extension is + uninstalled.

    What leaves the browser

    @@ -251,6 +261,9 @@ const subprocessors = [ Toddle's own chat. Like any web request, each request above lets the server that receives it see the device's IP address and browser type.

    +

    + {licenceSync.leaves} +

    A licence key carries the buyer's email address, and may also name who it is for: the buyer's email, or for an organisation licence the school's email domain. Where it diff --git a/src/pages/legal/privacy.astro b/src/pages/legal/privacy.astro index 678b7b8..a3a1b1c 100644 --- a/src/pages/legal/privacy.astro +++ b/src/pages/legal/privacy.astro @@ -13,6 +13,7 @@ import { dataProtection, extensionDataFacts, legal, + licenceSync, security, updatedLabel, } from "../../data/legal"; @@ -42,7 +43,7 @@ const description = extension makes only two connections outside Toddle: feedback you write in its menu and choose to send, and, while a licence is entered, a once-a-day download of a signed list of cancelled keys that sends nothing about you. Neither ever includes - anything from Toddle. We do hold your email address if you bought something, the + anything from Toddle. {licenceSync.short} We do hold your email address if you bought something, the way any shop does. This website asks before it measures anything and lets you take all, some or none of it; the extension measures nothing at all, and never will.

    diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index 6685ce4..e0f8a56 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -18,6 +18,7 @@ import { extensionVersions, flags, legal, + licenceSync, security, securityMailto, updatedLabel, @@ -51,7 +52,7 @@ const glance = [ { label: "Stores", value: - "Settings, a licence key, the last licence check's result and the email of the Toddle account last seen, on the device. No student data.", + `Settings, a licence key, the last licence check's result and the email of the Toddle account last seen, on the device; from ${security.next}, the licence key also in Chrome's sync, never sent to Nyuchi. No student data.`, }, { label: "Tested", @@ -335,14 +336,14 @@ const severityClass: Record = { school's email domain. Where it does, the extension compares that with the Toddle account signed in, inside the browser only, and sends it nowhere. A key that names no one works for any account until it expires. Activating a key contacts nothing, and the - key is never sent anywhere. It is kept in the extension's own storage, which Toddle's - page cannot reach. + extension never sends the key to Nyuchi or anywhere else. It is kept in the + extension's own storage, which Toddle's page cannot reach. {licenceSync.detail}

    No student data on the device

    In the extension's own storage: the teacher's switches, the licence key if one was - entered, the result of the last check for cancelled keys, and the email of the + entered (from {security.next}, also in Chrome's sync), 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. In Toddle's own site storage, four housekeeping values: a copy of the switches and of which features the licence allows, whether flags are concealed, the My classes choice, and the diff --git a/src/pages/legal/terms.astro b/src/pages/legal/terms.astro index 0e75d82..d9026ab 100644 --- a/src/pages/legal/terms.astro +++ b/src/pages/legal/terms.astro @@ -9,6 +9,7 @@ import BaseLayout from "../../layouts/BaseLayout.astro"; import { legal, + licenceSync, ownership, pricing, refunds, @@ -127,7 +128,7 @@ const description = address, and may also name who it is for: the buyer's email, for the Toddle account with that address, or for an organisation licence the school's email domain, for Toddle accounts on that domain. Where it does, the comparison happens in your browser - only. While a key is entered, the extension downloads once a day a signed list of + only. {licenceSync.short} While a key is entered, the extension downloads once a day a signed list of cancelled keys, sending nothing about you; a key we have cancelled stops unlocking the licensed features once the extension has seen that list. If the check cannot be made, the last result stands, so a network outage never switches a licence off. When a key diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index 774314b..87ddcce 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -22,6 +22,8 @@ import { flags, ownership, pricing, + licenceSync, + upgrade, refunds, toddleNotice, } from "../data/legal"; @@ -202,7 +204,7 @@ const facts = [ { label: "What it stores", value: - "On your own machine only: your switch settings, your licence key if you have 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; and, in Toddle's own site storage, a copy of the switches, whether flags are concealed, your My classes choice and the academic year Toddle is showing. No student data: what it reads is held in the tab's memory until the tab is closed or reloaded, and never written to storage.", + `On your own machine: your switch settings, your licence key if you have 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; and, in Toddle's own site storage, a copy of the switches, whether flags are concealed, your My classes choice and the academic year Toddle is showing. ${licenceSync.short} No student data: what it reads is held in the tab's memory until the tab is closed or reloaded, and never written to storage.`, }, ]; @@ -590,9 +592,14 @@ const schema = [ Checkout is through Buy Me a Coffee, and your licence key is emailed to you automatically, usually within a minute of paying. It is checked on your own machine, so activating it contacts nothing and works offline — and one key covers your laptop and your classroom - desktop. While a key is entered, the extension checks once a day for cancelled keys, + desktop. From version {extensionVersions.next} it follows your Chrome account, so you + enter it once; Chrome's sync carries it, and it is never sent to us. While a key is entered, the extension checks once a day for cancelled keys, sending nothing about you.

    +

    + Upgrading.{" "} + {upgrade.text} +

    Refunds.{" "} {`${refunds.rule} ${refunds.how} ${refunds.key}`} diff --git a/tests/seo.test.ts b/tests/seo.test.ts index 08555d0..a67fd82 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -253,7 +253,9 @@ describe("the releases page", () => { describe("prices", () => { /* The organisation licence is US$149.99 a year, confirmed by the founder. - Any other figure for it anywhere on the site is a mistake. */ + Any other figure for it anywhere on the site is a mistake. The one other + figure allowed is the upgrade code's discount, "US$7 off" (`upgrade` in + src/data/legal.ts). */ it("states the organisation price as US$149.99 everywhere", () => { for (const file of [ "toddle-enhancement-extension/index.html", @@ -262,7 +264,13 @@ describe("prices", () => { ]) { const text = built(file); expect(text, file).toContain("US$149.99"); - expect(text, file).not.toMatch(/US\$\s?(?!149\.99\b|5\b)\d/); + expect(text, file).not.toMatch(/US\$\s?(?!149\.99\b|5\b|7 off\b)\d/); } }); + + it("shows individual licence holders the upgrade to the school's licence", () => { + const page = built("toddle-enhancement-extension/index.html"); + expect(page).toContain("BXXF20VA"); + expect(page).toContain("US$7 off the organisation licence"); + }); }); From 05e0682fab72ccb5d7842ca84c6d6cfe282693bd Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 12:05:55 +0800 Subject: [PATCH 09/20] ci: a release to main is tagged as the next minor (#73) * ci: a release to main is tagged as the next minor The repo tagged staging merges as patches (staging-version.yml) but had nothing on main, so a release to main produced no tag. This calls the org's reusable auto-tag on main with the version computed from the tags (channel main: the next minor), the RELEASE_BUMP_TOKEN, and a GitHub release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * ci: the main version workflow runs only on main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/main-version.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/main-version.yml diff --git a/.github/workflows/main-version.yml b/.github/workflows/main-version.yml new file mode 100644 index 0000000..68e591a --- /dev/null +++ b/.github/workflows/main-version.yml @@ -0,0 +1,35 @@ +# 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 + +permissions: + contents: read + +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 From 4f205d4f8946dbfef7b8f2a502341c87861eb64d Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 12:15:04 +0800 Subject: [PATCH 10/20] docs: release review fixes: the licence host per version, one legal date, the changelog cut to 0.1.0 (#75) * docs: release review fixes: the licence host per version, one legal date, the changelog cut to 0.1.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: the README's version is the latest tag Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: the changelog's licence-host entry names the versions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: the staging patch tags start at v0.0.1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 38 ++++++++++++++++---- README.md | 2 +- public/llms.txt | 5 +-- src/data/legal.ts | 5 ++- src/pages/legal/data.astro | 3 +- src/pages/legal/privacy.astro | 4 +-- src/pages/legal/security.astro | 3 +- src/pages/toddle-enhancement-extension.astro | 6 ++-- 8 files changed, 47 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bf393..ffd59a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## Unreleased +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. + +## 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 @@ -18,12 +27,26 @@ ### 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. +- **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, and say that earlier versions - of the extension ask `licences.nyuchi.dev`, the same server, which still - answers. The legal pages' date moves to 7 October 2026. + 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. @@ -63,7 +86,8 @@ 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 - `licences.nyuchi.dev`, summarised on the extension page; the full guide + 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. @@ -82,7 +106,9 @@ 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 - are dated 6 October 2026. Checked against the extension's data schema + 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 diff --git a/README.md b/README.md index 5251753..7b69a4c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![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:** `main` | **Deploy:** Vercel +**Version:** the latest git tag (see [CHANGELOG.md](CHANGELOG.md)) | **Live:** [learning.nyuchi.com](https://learning.nyuchi.com) | **Default branch:** `main` | **Deploy:** Vercel --- diff --git a/public/llms.txt b/public/llms.txt index 3af4cb3..69b52f1 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -81,7 +81,8 @@ community-based platforms. Nyuchi Web Services is its development division. processed by Formspree; no student data, no page address, no licence key. (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 - (licences.nyuchi.dev, the same server, for earlier versions), + (from version 0.9.0; version 0.8.2, on the store today, asks + 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 @@ -135,7 +136,7 @@ community-based platforms. Nyuchi Web Services is its development division. ## Legal -All dated 6 October 2026. Operated by Nyuchi Web Services, the development +All dated 7 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 diff --git a/src/data/legal.ts b/src/data/legal.ts index bdf278e..33ec47c 100644 --- a/src/data/legal.ts +++ b/src/data/legal.ts @@ -61,7 +61,7 @@ export const dataProtection = { }, } as const; -/** The date above as people write it: "6 October 2026". */ +/** The date above as people write it: "7 October 2026". */ export const updatedLabel = new Date( `${legal.updated}T00:00:00Z`, ).toLocaleDateString("en-GB", { @@ -246,8 +246,7 @@ export const extensionDataFacts = [ { claim: "Outside connection two, only while a licence is entered: a daily check for cancelled keys.", - detail: - "At most once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licenses.nyuchi.com/v1/revocations (earlier versions ask licences.nyuchi.dev, the same server); a key just entered is checked within 5 minutes. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, checks its own key against that list on your machine, and keeps only the result. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the list cannot be fetched, the last result stands, so a network outage never switches a licence off.", + detail: `At most once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licenses.nyuchi.com/v1/revocations (from version ${next}; version ${current}, on the Chrome Web Store today, asks licences.nyuchi.dev, the same server); a key just entered is checked within 5 minutes. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, checks its own key against that list on your machine, and keeps only the result. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the list cannot be fetched, the last result stands, so a network outage never switches a licence off.`, }, { claim: "Exports are local.", diff --git a/src/pages/legal/data.astro b/src/pages/legal/data.astro index 8c78b7f..238f7db 100644 --- a/src/pages/legal/data.astro +++ b/src/pages/legal/data.astro @@ -247,7 +247,8 @@ const subprocessors = [

  • A daily check for cancelled keys, only while a licence is entered: one plain request from the extension's background worker to - https://licenses.nyuchi.com/v1/revocations (earlier versions ask + https://licenses.nyuchi.com/v1/revocations (from version + {extensionVersions.next}; version {extensionVersions.current}, on the store today, asks licences.nyuchi.dev, the same server), sending no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the SHA-256 fingerprints of cancelled keys and checks its own key locally. Cloudflare diff --git a/src/pages/legal/privacy.astro b/src/pages/legal/privacy.astro index a3a1b1c..1dea3a0 100644 --- a/src/pages/legal/privacy.astro +++ b/src/pages/legal/privacy.astro @@ -142,8 +142,8 @@ const description =
  • What is sent. One plain request to https://licenses.nyuchi.com/v1/revocations, from the extension's - background worker (earlier versions ask licences.nyuchi.dev, the - same server). It carries no licence key, no identifiers, no cookies and no + background worker (from version {security.next}; version {security.version}, on the + store today, asks licences.nyuchi.dev, the same server). It carries no licence key, no identifiers, no cookies and no Toddle data.
  • diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index e0f8a56..be6352d 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -314,7 +314,8 @@ const severityClass: Record = {
  • A daily check for cancelled keys. At most once a day, only while a licence is entered, the background worker makes one plain request - to https://licenses.nyuchi.com/v1/revocations (earlier versions ask + to https://licenses.nyuchi.com/v1/revocations (from version + {extensionVersions.next}; version {extensionVersions.current}, on the store today, asks licences.nyuchi.dev, the same server). It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, and diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index 87ddcce..a6f603e 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -199,7 +199,7 @@ const facts = [ { label: "Its two outside connections", value: - "The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licenses.nyuchi.com for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.", + `The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licenses.nyuchi.com (licences.nyuchi.dev, the same server, in version ${extensionVersions.current}, on the store today) for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.`, }, { label: "What it stores", @@ -513,8 +513,8 @@ const schema = [ it to staff; each teacher pastes it in once, from the welcome page or the toolbar menu's Enter a licence key. If your network filters outbound traffic, allow {toddleExtension.licenceHost} - (and licences.nyuchi.dev, which earlier versions - ask) so the daily check for cancelled keys can run. If it is blocked, licences keep + (and licences.nyuchi.dev, which version + {extensionVersions.current}, on the store today, asks) so the daily check for cancelled keys can run. If it is blocked, licences keep working on the last result.

    From edba47964954ea671c26b0e416702abb39d6c7c9 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 12:22:15 +0800 Subject: [PATCH 11/20] docs: second release review fixes: what 0.9.0 reads, versioned flag claims, fixed review history (#76) * docs: second release review fixes: what 0.9.0 reads, versioned flag claims, fixed review history Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: the flags summary keeps its full stop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * style: no blank line in flags Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/staging-version.yml | 2 ++ CHANGELOG.md | 7 +++++++ src/data/legal.ts | 8 ++++---- src/data/releases.ts | 2 +- src/pages/legal/data.astro | 6 ++++-- src/pages/legal/security.astro | 2 +- src/pages/legal/student-privacy.astro | 4 +++- src/pages/legal/terms.astro | 2 +- src/pages/toddle-enhancement-extension.astro | 4 ++-- 9 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/workflows/staging-version.yml b/.github/workflows/staging-version.yml index 4cc02fa..76bfb08 100644 --- a/.github/workflows/staging-version.yml +++ b/.github/workflows/staging-version.yml @@ -18,6 +18,8 @@ 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 || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd59a6..bc1be7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ Store review; the site describes it as the next version until it is live. 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 diff --git a/src/data/legal.ts b/src/data/legal.ts index 33ec47c..0f4e43d 100644 --- a/src/data/legal.ts +++ b/src/data/legal.ts @@ -173,8 +173,7 @@ export const upgrade = { * Checked against the extension's SECURITY.md and docs/data-schema.md. */ export const flags = { - summary: - "The extension never hides Toddle's own student flags: they show as Toddle shows them. When your screen is shown to a class, a parent or a visitor, one free switch conceals them across Toddle, so what a flag says cannot be read from across the room.", + summary: `From version ${next}, the extension never hides Toddle's own student flags: they show as Toddle shows them. When your screen is shown to a class, a parent or a visitor, one free switch conceals them across Toddle, so what a flag says cannot be read from across the room.`, reveal: 'In the student sidebar, "Show flags" shows one student\'s flags when you need them.', limit: @@ -203,7 +202,7 @@ export const extensionDataFacts = [ { claim: "What it reads, feature by feature.", detail: - "Toddle answers with what the signed-in teacher's own account may see, and no more. The gradebook tools, when a teacher expands an assessment: its assessment tools as Toddle defines them (rubric criteria, descriptors and levels, grade scales and their colours, checklist items, scores, standards and learning goals), and each assigned student's name, email, student ID, submission status and results, including written responses and comments. No descriptor or level is written in the extension. The home page: each class's staff, to show its primary teacher and the My classes filter. A student's profile page: the teachers of each of the student's classes. The Attendance dashboard: each student's year group and the names of the periods, from Toddle's own request, and the primary teacher of the class each student is in now, for the “Now: class · teacher” line. The student sidebar, when a teacher opens a student or a class: the student's name, photo, year group and age (the date of birth is used to work out the age and is never shown), email, student ID and enrolment date; their family accounts and contacts, with names, relationships, phone numbers and emails; their homeroom advisor; the school's additional profile fields, of which only Student Group and Room number are ever shown; today's timetable and attendance marks; their active flags (see the next point); and each class's teachers, with their display titles and emails.", + "Toddle answers with what the signed-in teacher's own account may see, and no more. The gradebook tools, when a teacher expands an assessment: its assessment tools as Toddle defines them (rubric criteria, descriptors and levels, grade scales and their colours, checklist items, scores, standards and learning goals), and each assigned student's name, email, student ID, submission status and results, including written responses and comments. No descriptor or level is written in the extension. The home page: each class's staff, to show its primary teacher and the My classes filter. A student's profile page: the teachers of each of the student's classes. The Attendance dashboard: each student's year group and the names of the periods, from Toddle's own request, and the primary teacher of the class each student is in now, for the “Now: class · teacher” line. The student sidebar, when a teacher opens a student or a class: the student's name, photo, year group and age (the date of birth is used to work out the age and is never shown), email, student ID and enrolment date; their family accounts and contacts, with names, relationships, phone numbers and emails; their homeroom advisor; the school's additional profile fields, of which only Student Group and Room number are ever shown; today's timetable and attendance marks; their active flags (see the next point); and the teachers of each class, of today's blocks and of the homeroom, with their display titles, roles and emails, and their photos where Toddle gives them, which the sidebar's staff view shows when a teacher's name is clicked (it asks Toddle for nothing more).", }, { claim: @@ -277,7 +276,8 @@ export const security = { { date: "2026-10-01", label: "1 October 2026", - version: extensionVersions.current, + /** Fixed: review history does not move when `current` does. */ + version: "0.8.2", findings: "1 to 12", }, { diff --git a/src/data/releases.ts b/src/data/releases.ts index 4c34350..6e3dffb 100644 --- a/src/data/releases.ts +++ b/src/data/releases.ts @@ -121,7 +121,7 @@ export const releases: Release[] = [ items: [ "The flags switch, the student sidebar, My classes and teacher names, the gradebook's toolbar and controls, and the Attendance dashboard's details now run in the extension's own part of the browser, inside closed shadow roots that no script on Toddle's page can read or click into. Whether each one runs is decided by your switches and licence, never by Toddle's page.", "Each rebuilt part was given its own security review on 6 October 2026, as were flag visibility and the sidebar's new staff and class views. Everything they found is fixed, each with a test. The Security page has the details.", - "Nothing new is read from Toddle, and the extension sends nothing new anywhere. The one change is Chrome's: it now syncs your licence key through your own Chrome account, never to Nyuchi.", + "Everything it reads, including what is new in this release, comes from Toddle, as you, and stays in your browser; the data handling page lists each read. The extension sends nothing new anywhere. The one change is Chrome's: it now syncs your licence key through your own Chrome account, never to Nyuchi.", ], }, ], diff --git a/src/pages/legal/data.astro b/src/pages/legal/data.astro index 238f7db..5fb9edf 100644 --- a/src/pages/legal/data.astro +++ b/src/pages/legal/data.astro @@ -134,8 +134,10 @@ const subprocessors = [ work out the age and is never shown), email, student ID, enrolment date, family accounts and contacts with relationship, phone numbers and email, homeroom advisor, the school's additional profile fields (only Student Group and Room number are ever - shown), today's timetable and attendance marks, and each class's teachers with - their display titles and emails. + shown), today's timetable and attendance marks, and the teachers of each class, of + today's blocks and of the homeroom, with their display titles, roles and emails, and + their photos where Toddle gives them, which the sidebar's staff view shows when a + teacher's name is clicked (it asks Toddle for nothing more).

  • Student flags. From version {extensionVersions.next}, the extension no longer hides Toddle's own flags, and a staff member can choose diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index be6352d..fc1e711 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -512,7 +512,7 @@ const severityClass: Record = { that runs in Toddle's page against the claims made about it. It found two medium-severity issues and two low (findings {secondReview.findings}). All were fixed in version {secondReview.version}, and the fixes are in every release after it, - including {security.next}. None was in version {security.version}. Each later + including {security.next}. None was in version 0.8.2. Each later step of the rebuild (the student sidebar, the home page, the gradebook's toolbar and the Attendance dashboard's details) was reviewed the same way as it was made, and what those reviews found about the page's limits is set out below. diff --git a/src/pages/legal/student-privacy.astro b/src/pages/legal/student-privacy.astro index b3dce6b..7a6aae8 100644 --- a/src/pages/legal/student-privacy.astro +++ b/src/pages/legal/student-privacy.astro @@ -86,7 +86,9 @@ const privacyMail = `mailto:${legal.privacyEmail}`; so what a flag says cannot be read from across the room. {flags.reveal}

    - The sidebar reads a student's active flags from Toddle each time it opens, shows them + From version {extensionVersions.next}, the sidebar reads a student's active flags from + Toddle each time it opens. {flags.before} In that version, while flags are hidden, it + asks only when someone presses its eye button. The sidebar shows them in that teacher's browser only, and forgets them when it closes. They are never stored and never sent anywhere. {flags.limit}

    diff --git a/src/pages/legal/terms.astro b/src/pages/legal/terms.astro index d9026ab..c9ca1a7 100644 --- a/src/pages/legal/terms.astro +++ b/src/pages/legal/terms.astro @@ -128,7 +128,7 @@ const description = address, and may also name who it is for: the buyer's email, for the Toddle account with that address, or for an organisation licence the school's email domain, for Toddle accounts on that domain. Where it does, the comparison happens in your browser - only. {licenceSync.short} While a key is entered, the extension downloads once a day a signed list of + only. {licenceSync.short} While a key is entered, the extension downloads at most once a day a signed list of cancelled keys, sending nothing about you; a key we have cancelled stops unlocking the licensed features once the extension has seen that list. If the check cannot be made, the last result stands, so a network outage never switches a licence off. When a key diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index a6f603e..722b96f 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -233,7 +233,7 @@ const schema = [ publisher: { "@id": "https://learning.nyuchi.com/#organization" }, featureList: [ "Expands every Toddle assessment tool into per-criterion columns", - "Conceals student flags across Toddle with one free switch, for a shared screen; never hides Toddle's own flags", + "Conceals student flags across Toddle with one free switch, for a shared screen; from version 0.9.0 never hides Toddle's own flags", "CSV export of results and submission status", "A My classes filter on the Toddle home page", "A student sidebar anywhere a student appears in Toddle: photo, grade, email, contacts, where they are now, today's timetable and attendance", @@ -593,7 +593,7 @@ const schema = [ automatically, usually within a minute of paying. It is checked on your own machine, so activating it contacts nothing and works offline — and one key covers your laptop and your classroom desktop. From version {extensionVersions.next} it follows your Chrome account, so you - enter it once; Chrome's sync carries it, and it is never sent to us. While a key is entered, the extension checks once a day for cancelled keys, + enter it once; Chrome's sync carries it, and it is never sent to us. While a key is entered, the extension checks at most once a day for cancelled keys, sending nothing about you.

    From 2f927b5ec414e70d87cee8c605839d3c536c7d05 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 20:58:40 +0800 Subject: [PATCH 12/20] docs: the extension's 0.9.0 is on the Chrome Web Store (#78) * docs: the extension's 0.9.0 is on the Chrome Web Store 0.9.0 was approved and 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, with no `next` until another minor waits for the store; `previous` (0.8.2) names the release before it where updating from it still matters. The "0.8.2, on the store today" wording and every "from version 0.9.0" are gone from the product page, the legal pages, llms.txt and SECURITY.md; what helps a teacher updating from 0.8.2 stays, as history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq * docs: review fixes: 0.9.0 reached schools, versions before 0.9.0, grammar Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GHjaH1Vt2dVLWU68iz8tFq --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 17 ++++++ SECURITY.md | 3 +- public/llms.txt | 33 ++++++----- src/data/legal.ts | 56 +++++++++---------- src/data/releases.ts | 3 +- src/pages/legal/cookies.astro | 2 +- src/pages/legal/data.astro | 26 ++++----- src/pages/legal/privacy.astro | 9 +-- src/pages/legal/security.astro | 31 +++++----- src/pages/legal/student-privacy.astro | 9 +-- src/pages/toddle-enhancement-extension.astro | 28 +++++----- .../releases.astro | 4 +- tests/links.test.ts | 5 +- tests/seo.test.ts | 6 +- 14 files changed, 119 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc1be7b..51ce917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ 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 + +### Changed + +- **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`. + ## 0.1.0 — 2026-10-07 Released with the Toddle Enhancement Extension 0.9.0, which is in Chrome Web diff --git a/SECURITY.md b/SECURITY.md index ff65db2..455deb0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,8 @@ Vercel, and the home of the Toddle Enhancement Extension. 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 reach schools in 0.9.0), with their findings and the limits of code that runs in Toddle's +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 diff --git a/public/llms.txt b/public/llms.txt index 69b52f1..d70c1d8 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -18,19 +18,20 @@ community-based platforms. Nyuchi Web Services is its development division. 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 gives teachers control of flag visibility on a - shared screen. From version 0.9.0 (the next Chrome Web Store release), - the extension no longer hides Toddle's own student flags; a teacher can + 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. In version 0.8.2, on the store - today, flags are hidden from the start and the switch shows or hides them. + 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. From 0.9.0 an individual + 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, @@ -45,7 +46,7 @@ community-based platforms. Nyuchi Web Services is its development division. 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. From version 0.9.0 the flag switch, + 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 @@ -70,10 +71,8 @@ community-based platforms. Nyuchi Web Services is its development division. 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. From version 0.9.0 the sidebar asks Toddle for a student's - active flags each time it opens, and keeps them only while it is open; in - version 0.8.2, while flags are hidden, it asks only when someone presses - its eye button. Apart + 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 @@ -81,8 +80,7 @@ community-based platforms. Nyuchi Web Services is its development division. processed by Formspree; no student data, no page address, no licence key. (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 - (from version 0.9.0; version 0.8.2, on the store today, asks - licences.nyuchi.dev, the same server), + (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 @@ -93,7 +91,7 @@ community-based platforms. Nyuchi Web Services is its development division. 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). From version 0.9.0 the licence key alone is also + 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 @@ -108,9 +106,10 @@ community-based platforms. Nyuchi Web Services is its development division. 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.9.0 next) - from the Chrome Web Store; versions 0.8.3 to 0.8.13 were test builds for - schools piloting it, and their changes arrive together in 0.9.0. Release + cannot be pushed by policy. Teachers get each minor release (0.9.0 is + the current one) 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 @@ -179,7 +178,7 @@ 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, never sent to Nyuchi, and from 0.9.0 synced only through + 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 diff --git a/src/data/legal.ts b/src/data/legal.ts index 0f4e43d..95712cd 100644 --- a/src/data/legal.ts +++ b/src/data/legal.ts @@ -114,28 +114,28 @@ export const pricing = [ * (0.9.0, …) is released from the extension's `main` branch and published to * the store. Teachers get minors. * - * `current` is what teachers have from the Chrome Web Store (the v0.8.3 tag - * carries the same code). `next` is the next minor: everything under - * Unreleased in the extension's changelog, reviewed, already in the patch - * releases up to `latestPatch`, and not yet on the store. Claims that differ - * between the two say "from version 0.9.0" and say what `current` does, so - * they stay true on both sides of the release. When `next` ships, move it to - * `current` and drop the "before" wording. + * `current` is what teachers have from the Chrome Web Store: 0.9.0, published + * on 7 October 2026. `previous` is the store release before it, named only + * where something it did still matters to a teacher updating from it (flags + * it hid stay concealed), said as history. What changed in the test builds + * between the two (0.8.3 carried 0.8.2's code) is said as "versions before + * `current`": the old licence host and the old flags storage key. There is no `next` while no minor is reviewed and waiting for the + * store. When one is, add `next` here, say "from version " and what + * `current` does wherever the two differ, so each claim stays true on both + * sides of the release; when it ships, move it to `current` and drop that + * wording again. */ export const extensionVersions = { - current: "0.8.2", - sameCodeAs: "0.8.3", - next: "0.9.0", - /** The newest patch release (GitHub only, for testing). */ - latestPatch: "0.8.13", + current: "0.9.0", + previous: "0.8.2", /** The oldest version security reports are accepted for. */ supportedFrom: "0.8.2", } as const; -const { current, next } = extensionVersions; +const { current, previous } = extensionVersions; /** - * The licence key and Chrome sync, said once. From 0.9.0 the key alone is + * The licence key and Chrome sync, said once. Since 0.9.0 the key alone is * also kept in chrome.storage.sync, so it follows the teacher's own Chrome * account. That is Chrome's sync, not Nyuchi's: the extension never sends the * key to Nyuchi. Checked against the extension's SECURITY.md and @@ -143,11 +143,11 @@ const { current, next } = extensionVersions; */ export const licenceSync = { /** One sentence, for summaries. */ - short: `From version ${next}, your licence key also follows your own Chrome account to your other computers, through Chrome's sync; it is never sent to Nyuchi.`, + short: `Your licence key also follows your own Chrome account to your other computers, through Chrome's sync; it is never sent to Nyuchi.`, /** The full account, for the data pages. */ - detail: `From version ${next}, the licence key, and nothing else, is also kept in Chrome's synced storage for the extension, so Chrome carries it to the other computers signed in to the same Chrome account, unless sync, or a school's Chrome policy, turns that off. That is Chrome's own sync, through your Google account; the extension never sends the key to Nyuchi. A key carries the buyer's email address, so that travels with it. The newest key wins on every computer, and removing it on one removes it on all of them. Who a key is for, and the result of the check for cancelled keys, stay on each computer.`, + detail: `The licence key, and nothing else, is also kept in Chrome's synced storage for the extension, so Chrome carries it to the other computers signed in to the same Chrome account, unless sync, or a school's Chrome policy, turns that off. That is Chrome's own sync, through your Google account; the extension never sends the key to Nyuchi. A key carries the buyer's email address, so that travels with it. The newest key wins on every computer, and removing it on one removes it on all of them. Who a key is for, and the result of the check for cancelled keys, stay on each computer.`, /** For "what leaves the device". */ - leaves: `One thing more leaves your device from version ${next}, and not to us: with Chrome sync on, Chrome carries your licence key to your other computers through your own Chrome account. The extension itself never sends the key anywhere, Nyuchi included.`, + leaves: `One thing more leaves your device, and not to us: with Chrome sync on, Chrome carries your licence key to your other computers through your own Chrome account. The extension itself never sends the key anywhere, Nyuchi included.`, } as const; /** @@ -161,11 +161,11 @@ const upgradeOff = "US$7"; export const upgrade = { code: upgradeCode, off: upgradeOff, - text: `Already have an individual licence? Bring your whole school in. From version ${next} the extension's licence page shows individual licence holders the upgrade, with the code ${upgradeCode}, which takes ${upgradeOff} off the organisation licence (it works on that licence only). Your school's key then replaces yours on every computer.`, + text: `Already have an individual licence? Bring your whole school in. The extension's licence page shows individual licence holders the upgrade, with the code ${upgradeCode}, which takes ${upgradeOff} off the organisation licence (it works on that licence only). Your school's key then replaces yours on every computer.`, } as const; /** - * Student flags, said once. From 0.9.0 the extension never hides Toddle's own + * Student flags, said once. Since 0.9.0 the extension never hides Toddle's own * flags: a teacher can conceal them for a shared screen. The wording is * "conceal" and "flag visibility", never "hide", and it promises no more than * the extension does: how a concealed flag looks is still being refined, so @@ -173,12 +173,11 @@ export const upgrade = { * Checked against the extension's SECURITY.md and docs/data-schema.md. */ export const flags = { - summary: `From version ${next}, the extension never hides Toddle's own student flags: they show as Toddle shows them. When your screen is shown to a class, a parent or a visitor, one free switch conceals them across Toddle, so what a flag says cannot be read from across the room.`, + summary: `The extension never hides Toddle's own student flags: they show as Toddle shows them. When your screen is shown to a class, a parent or a visitor, one free switch conceals them across Toddle, so what a flag says cannot be read from across the room.`, reveal: 'In the student sidebar, "Show flags" shows one student\'s flags when you need them.', limit: "Concealing is a screen, not a lock. A concealed flag can still show that a student has a flag. Concealing changes only what is drawn on the screen, never Toddle's data, and does not stop anyone at the computer opening a student's flags in Toddle.", - before: `In version ${current}, on the Chrome Web Store today, the extension hides flags from the start, and the switch shows or hides them.`, } as const; /** @@ -207,7 +206,7 @@ export const extensionDataFacts = [ { claim: "Student flags: Toddle's own are never hidden, and a teacher can conceal them for a shared screen.", - detail: `From version ${next}, the extension no longer hides Toddle's own student flags: they show as Toddle shows them, including in Toddle's student popover, with their links and documents. A teacher can choose to conceal flags, for a screen a class or a visitor can see, with the free flag switch in Toddle's top bar or the extension's menu. A new install starts with flags shown. A teacher updating from version ${current}, where flags were hidden by default, or who had hidden them, has them concealed after the update. A concealed flag is a faint grey smudge with no colour, and its words cannot be read from across a room, though it can still show that a student has a flag. Nothing on the page brings a concealed flag back. ${flags.reveal} The sidebar asks Toddle for a student's active flags each time it opens, and keeps them only while that student's sidebar is open. Concealing changes only what is drawn on the screen, never Toddle's data. ${flags.before} In that version, while flags are hidden, the sidebar asks for a student's flags only when someone presses its eye button. The flag switch is free and always will be.`, + detail: `The extension does not hide Toddle's own student flags: they show as Toddle shows them, including in Toddle's student popover, with their links and documents. A teacher can choose to conceal flags, for a screen a class or a visitor can see, with the free flag switch in Toddle's top bar or the extension's menu. A new install starts with flags shown. A teacher who updated from version ${previous}, where flags were hidden by default, or who had hidden them, has them concealed after the update until they choose to show them. A concealed flag is a faint grey smudge with no colour, and its words cannot be read from across a room, though it can still show that a student has a flag. Nothing on the page brings a concealed flag back. ${flags.reveal} The sidebar asks Toddle for a student's active flags each time it opens, and keeps them only while that student's sidebar is open. Concealing changes only what is drawn on the screen, never Toddle's data. The flag switch is free and always will be.`, }, { claim: "Your Toddle sign-in stays with Toddle.", @@ -230,7 +229,7 @@ export const extensionDataFacts = [ }, { claim: "What it stores: settings and licence details, and no student data.", - detail: `In Chrome's storage for the extension, on your device: your switches (the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on the Attendance dashboard); your licence key, if you entered one; the result of the last check for cancelled keys (whether your key is on the list, the list's date and when the check was made, never the list itself); and the email address of the Toddle account last seen signed in, with the time, so the extension can check who a licence is for. That email is read on every Toddle page, with or without a licence key, and stays on the device. ${licenceSync.detail} In the browser's storage for Toddle's own site: tee-settings, a copy of your switches and of which features the licence allows (no key and no email); tee-blur-flags (gbx-hide-flags in version ${current}), whether flags are concealed, so they are concealed before the page is drawn; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. Nothing it reads about students, classes, results or attendance is ever written to storage. Version ${current} has three switches: student flags, the student sidebar and primary teacher names.`, + detail: `In Chrome's storage for the extension, on your device: your switches (the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on the Attendance dashboard); your licence key, if you entered one; the result of the last check for cancelled keys (whether your key is on the list, the list's date and when the check was made, never the list itself); and the email address of the Toddle account last seen signed in, with the time, so the extension can check who a licence is for. That email is read on every Toddle page, with or without a licence key, and stays on the device. ${licenceSync.detail} In the browser's storage for Toddle's own site: tee-settings, a copy of your switches and of which features the licence allows (no key and no email); tee-blur-flags (gbx-hide-flags in versions before ${current}), whether flags are concealed, so they are concealed before the page is drawn; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. Nothing it reads about students, classes, results or attendance is ever written to storage.`, }, { claim: "What it holds in memory, and for how long.", @@ -245,7 +244,7 @@ export const extensionDataFacts = [ { claim: "Outside connection two, only while a licence is entered: a daily check for cancelled keys.", - detail: `At most once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licenses.nyuchi.com/v1/revocations (from version ${next}; version ${current}, on the Chrome Web Store today, asks licences.nyuchi.dev, the same server); a key just entered is checked within 5 minutes. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, checks its own key against that list on your machine, and keeps only the result. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the list cannot be fetched, the last result stands, so a network outage never switches a licence off.`, + detail: `At most once a day, and only while a licence key is entered, the extension's background worker makes one plain request to https://licenses.nyuchi.com/v1/revocations (versions before ${current} asked licences.nyuchi.dev, the same server); a key just entered is checked within 5 minutes. It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, checks its own key against that list on your machine, and keeps only the result. Cloudflare, which runs the server, sees your IP address as with any web request; Nyuchi does not log it. If the list cannot be fetched, the last result stands, so a network outage never switches a licence off.`, }, { claim: "Exports are local.", @@ -254,22 +253,19 @@ export const extensionDataFacts = [ }, { claim: "Removing it removes what it stored.", - detail: `Uninstalling the extension deletes everything in its own storage on that computer. The four small values on Toddle's site stay until the browser's data for web.toddleapp.com is cleared; none of them is about a student. Removing a licence key deletes the key (from version ${next}, on every computer signed in to the same Chrome account, so remove the licence before uninstalling if you want it gone from your Chrome account too); the last check's result and the Toddle account's email stay until the extension is uninstalled.`, + detail: `Uninstalling the extension deletes everything in its own storage on that computer. The four small values on Toddle's site stay until the browser's data for web.toddleapp.com is cleared; none of them is about a student. Removing a licence key deletes the key (on every computer signed in to the same Chrome account, so remove the licence before uninstalling if you want it gone from your Chrome account too); the last check's result and the Toddle account's email stay until the extension is uninstalled.`, }, ] as const; /** * The extension's security posture, for the Security page. Each line is a - * claim about the published build (`current`) or the reviewed next one - * (`next`) that can be checked against the extension's source, tests, + * claim about the published build (`current`) that can be checked against the extension's source, tests, * SECURITY.md and docs/security-review.md; when the extension changes, this * changes with it. */ export const security = { /** The published version these statements describe. */ version: extensionVersions.current, - /** The reviewed version not yet published, which they also describe. */ - next: extensionVersions.next, supportedFrom: extensionVersions.supportedFrom, /** The adversarial reviews, oldest first. */ reviews: [ @@ -284,7 +280,7 @@ export const security = { date: "2026-10-06", label: "6 October 2026", /** The code reviewed: the 0.8.4 patch, the rebuild's first steps. Its - fixes are in every later release, and in `next`. */ + fixes are in every later release, and in 0.9.0. */ version: "0.8.4", findings: "13 to 16", }, diff --git a/src/data/releases.ts b/src/data/releases.ts index 6e3dffb..d4b75c7 100644 --- a/src/data/releases.ts +++ b/src/data/releases.ts @@ -66,7 +66,8 @@ export const testBuilds = { export const releases: Release[] = [ { version: "0.9.0", - status: "coming", + status: "store", + date: "2026-10-07", summary: "Everything since 0.8.2, in one release: student flags you control with one switch, a sidebar that tells you more about the people around a student, a licence that follows your Chrome account, and the extension rebuilt so that Toddle's own page can't reach into it.", sections: [ diff --git a/src/pages/legal/cookies.astro b/src/pages/legal/cookies.astro index e1f4bba..264600c 100644 --- a/src/pages/legal/cookies.astro +++ b/src/pages/legal/cookies.astro @@ -201,7 +201,7 @@ const rows = [ storage: tee-settings, a copy of the switches and of which features the licence allows (not the key, and no email); tee-blur-flags (gbx-hide-flags in version {extensionVersions.current}), whether flags are concealed; + > in versions before {extensionVersions.current}), whether flags are concealed; tee-course-view, whether you chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle is showing. None of it is about a student, and the extension sends none of it diff --git a/src/pages/legal/data.astro b/src/pages/legal/data.astro index 5fb9edf..329957b 100644 --- a/src/pages/legal/data.astro +++ b/src/pages/legal/data.astro @@ -12,7 +12,6 @@ import BaseLayout from "../../layouts/BaseLayout.astro"; import { dataProtection, extensionVersions, - flags, legal, licenceSync, refunds, @@ -140,12 +139,10 @@ const subprocessors = [ teacher's name is clicked (it asks Toddle for nothing more).

  • - Student flags. From version {extensionVersions.next}, the extension no longer hides Toddle's own flags, and a staff member can choose + Student flags. The extension does not hide Toddle's own flags, and a staff member can choose to conceal them for a shared screen with the free flag switch. The sidebar asks Toddle for a student's active flags (title, description, type and date) each time it opens, - and keeps them only while that student's sidebar is open. {flags.before} In that - version, while flags are hidden, the sidebar asks for them only when someone - presses its eye button. + and keeps them only while that student's sidebar is open.
  • From Toddle's own requests: the sign-in @@ -185,12 +182,11 @@ const subprocessors = [
  • the teacher's switches: the extension on or off, student flags, the student sidebar, My classes, primary teacher names, gradebook tools, and student details on - the Attendance dashboard (version {extensionVersions.current} has three: student - flags, the student sidebar and primary teacher names); + the Attendance dashboard;
  • - the licence key, if one was entered (from version {extensionVersions.next} also kept - in Chrome's synced storage; see below); + the licence key, if one was entered (also kept in Chrome's synced storage; see + below);
  • the result of the last check for cancelled keys: whether the key is on the list, the @@ -205,7 +201,7 @@ const subprocessors = [

    And four small values in the browser's storage for Toddle's own site: tee-settings, a copy of the switches and of which features the licence allows (no key and no - email); tee-blur-flags (gbx-hide-flags in version {extensionVersions.current}), whether flags are concealed; tee-course-view, + email); tee-blur-flags (gbx-hide-flags in versions before {extensionVersions.current}), whether flags are concealed; tee-course-view, whether the teacher chose My classes in the home page filter; and tee-academic-year, the number of the academic year Toddle last asked for. No student data is stored. @@ -218,8 +214,8 @@ const subprocessors = [

    Uninstalling the extension deletes everything in its own storage on that computer. The four values on Toddle's site stay until the browser's data for - web.toddleapp.com is cleared. Removing a licence key deletes the key (from - version {extensionVersions.next}, on every computer signed in to the same Chrome + web.toddleapp.com is cleared. Removing a licence key deletes the key (on + every computer signed in to the same Chrome account, so remove it before uninstalling if you want it gone from your Chrome account too); the last check's result and the account email stay until the extension is uninstalled. @@ -249,9 +245,9 @@ const subprocessors = [

  • A daily check for cancelled keys, only while a licence is entered: one plain request from the extension's background worker to - https://licenses.nyuchi.com/v1/revocations (from version - {extensionVersions.next}; version {extensionVersions.current}, on the store today, asks - licences.nyuchi.dev, the same server), sending no licence key, no + https://licenses.nyuchi.com/v1/revocations (versions + before {extensionVersions.current} asked licences.nyuchi.dev, the + same server), sending no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the 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 list diff --git a/src/pages/legal/privacy.astro b/src/pages/legal/privacy.astro index 1dea3a0..58b8210 100644 --- a/src/pages/legal/privacy.astro +++ b/src/pages/legal/privacy.astro @@ -12,6 +12,7 @@ import BaseLayout from "../../layouts/BaseLayout.astro"; import { dataProtection, extensionDataFacts, + extensionVersions, legal, licenceSync, security, @@ -142,8 +143,8 @@ const description =
  • What is sent. One plain request to https://licenses.nyuchi.com/v1/revocations, from the extension's - background worker (from version {security.next}; version {security.version}, on the - store today, asks licences.nyuchi.dev, the same server). It carries no licence key, no identifiers, no cookies and no + background worker (versions before {extensionVersions.current} asked + licences.nyuchi.dev, the same server). It carries no licence key, no identifiers, no cookies and no Toddle data.
  • @@ -171,8 +172,8 @@ const description = adversarial reviews. Every finding of the first, on {security.reviews[0].label}, was fixed in version {security.reviews[0].version}; every finding of the second, on {security.reviews[1].label}, of the code for version {security.reviews[1].version}, a test release, was fixed in - that release, and the fixes are in version {security.next}, the next on the Chrome - Web Store. + that release, and the fixes are in version {security.version}, on the Chrome Web + Store. The Security page sets out the security model, how it is tested, what the reviews found, the limits no extension can remove, and how to report a vulnerability.

    diff --git a/src/pages/legal/security.astro b/src/pages/legal/security.astro index fc1e711..6e29780 100644 --- a/src/pages/legal/security.astro +++ b/src/pages/legal/security.astro @@ -9,8 +9,8 @@ * policy is at /legal/vulnerability-disclosure. * * Every statement is about the published build named in `security.version` - * and the reviewed next one in `security.next` (src/data/legal.ts); where they - * differ, the page says which. When the extension's security model changes, + * (src/data/legal.ts). When a reviewed minor is waiting for the store, the + * page says where it differs. When the extension's security model changes, * this page changes with it, and the date at the top moves. */ import BaseLayout from "../../layouts/BaseLayout.astro"; @@ -52,7 +52,7 @@ const glance = [ { label: "Stores", value: - `Settings, a licence key, the last licence check's result and the email of the Toddle account last seen, on the device; from ${security.next}, the licence key also in Chrome's sync, never sent to Nyuchi. No student data.`, + `Settings, a licence key, the last licence check's result and the email of the Toddle account last seen, on the device; the licence key also in Chrome's sync, never sent to Nyuchi. No student data.`, }, { label: "Tested", @@ -61,7 +61,7 @@ const glance = [ }, { label: "Reviewed", - value: `Two adversarial reviews: ${firstReview.label}, every finding fixed in ${firstReview.version}; and ${secondReview.label}, every finding fixed in ${secondReview.version}, a test build, whose fixes reach schools in 0.9.0.`, + value: `Two adversarial reviews: ${firstReview.label}, every finding fixed in ${firstReview.version}; and ${secondReview.label}, every finding fixed in ${secondReview.version}, a test build, whose fixes reached schools in ${security.version}.`, }, ]; @@ -191,11 +191,8 @@ const severityClass: Record = {

    Last updated {updatedLabel}. Describes the {toddleExtension.name}: version {security.version}, - the one on the Chrome Web Store ({extensionVersions.sameCodeAs} carries the same code), - and version {security.next}, the next release on the store, reviewed and not yet - published. Between store releases, fixes are tested as patch releases (the newest is - {extensionVersions.latestPatch}), which do not go to the store. Where {security.version} - and {security.next} differ, this page says so. For schools, and for the privacy, legal and + the one on the Chrome Web Store. Between store releases, fixes are tested as patch + releases, which do not go to the store. For schools, and for the privacy, legal and IT colleagues who review software on their behalf.

    @@ -314,9 +311,9 @@ const severityClass: Record = {
  • A daily check for cancelled keys. At most once a day, only while a licence is entered, the background worker makes one plain request - to https://licenses.nyuchi.com/v1/revocations (from version - {extensionVersions.next}; version {extensionVersions.current}, on the store today, asks - licences.nyuchi.dev, the same server). It + to https://licenses.nyuchi.com/v1/revocations (versions + before {extensionVersions.current} asked licences.nyuchi.dev, the + same server). It sends no licence key, no identifiers, no cookies and no Toddle data. It downloads a list, signed by Nyuchi, of the fingerprints (SHA-256 hashes) of cancelled keys, and checks its own key against it locally. Cloudflare sees the IP address, as with any @@ -344,7 +341,7 @@ const severityClass: Record = {

    No student data on the device

    In the extension's own storage: the teacher's switches, the licence key if one was - entered (from {security.next}, also in Chrome's sync), the result of the last check for cancelled keys, and the email of the + entered (also in Chrome's sync), 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. In Toddle's own site storage, four housekeeping values: a copy of the switches and of which features the licence allows, whether flags are concealed, the My classes choice, and the @@ -354,10 +351,10 @@ const severityClass: Record = { minutes, and a student's day for at most 2, before Toddle is asked again.

    - From version {security.next}, the extension no longer hides Toddle's own student flags; a + The extension does not hide Toddle's own student flags; a staff member can choose to conceal them for a shared screen with the free flag switch. The sidebar asks Toddle for a student's flags each time it opens, and keeps - them only while it is open. {flags.before} The CSV export is made in the browser and + them only while it is open. The CSV export is made in the browser and saved where the teacher's downloads go, by the teacher's choice.

    @@ -366,7 +363,7 @@ const severityClass: Record = {

    The extension is being rebuilt so that as little of it as possible runs where - Toddle's own scripts run. From version {security.next}, the flag switch, the student + Toddle's own scripts run. In version {security.version}, the flag switch, the student sidebar with a class's teachers, the home page's My classes option and teacher names, the gradebook's toolbar and controls, and the Attendance dashboard's details run in the extension's own isolated part of the browser. What they draw on @@ -512,7 +509,7 @@ const severityClass: Record = { that runs in Toddle's page against the claims made about it. It found two medium-severity issues and two low (findings {secondReview.findings}). All were fixed in version {secondReview.version}, and the fixes are in every release after it, - including {security.next}. None was in version 0.8.2. Each later + including {security.version}. None was in version 0.8.2. Each later step of the rebuild (the student sidebar, the home page, the gradebook's toolbar and the Attendance dashboard's details) was reviewed the same way as it was made, and what those reviews found about the page's limits is set out below. diff --git a/src/pages/legal/student-privacy.astro b/src/pages/legal/student-privacy.astro index 7a6aae8..811d63a 100644 --- a/src/pages/legal/student-privacy.astro +++ b/src/pages/legal/student-privacy.astro @@ -9,7 +9,6 @@ import BaseLayout from "../../layouts/BaseLayout.astro"; import { dataProtection, - extensionVersions, flags, legal, updatedLabel, @@ -79,16 +78,14 @@ const privacyMail = `mailto:${legal.privacyEmail}`;

    Student flags

    Toddle lets a school mark students with flags, and those flags are the most sensitive - thing a teacher's screen can show. From version {extensionVersions.next}, the - extension no longer hides Toddle's own flags, so staff see what the school has recorded. + thing a teacher's screen can show. The extension does not hide Toddle's own + flags, so staff see what the school has recorded. What it adds is flag visibility for a shared screen: a teacher can conceal flags with one free switch before the screen is shown to a class, a parent or a visitor, so what a flag says cannot be read from across the room. {flags.reveal}

    - From version {extensionVersions.next}, the sidebar reads a student's active flags from - Toddle each time it opens. {flags.before} In that version, while flags are hidden, it - asks only when someone presses its eye button. The sidebar shows them + The sidebar reads a student's active flags from Toddle each time it opens, and shows them in that teacher's browser only, and forgets them when it closes. They are never stored and never sent anywhere. {flags.limit}

    diff --git a/src/pages/toddle-enhancement-extension.astro b/src/pages/toddle-enhancement-extension.astro index 722b96f..5f69170 100644 --- a/src/pages/toddle-enhancement-extension.astro +++ b/src/pages/toddle-enhancement-extension.astro @@ -8,8 +8,9 @@ * switch gets the most room because it is the part people will be asked to * justify. * - * It describes version 0.9.0 (`extensionVersions.next`), the next release on - * the Chrome Web Store, and says where 0.8.2, on the store today, differs. + * It describes version 0.9.0 (`extensionVersions.current`), on the Chrome Web + * Store since 7 October 2026, and names 0.8.2 only where updating from it + * matters. * Flags are "concealed", never "hidden": the extension no longer hides * Toddle's own flags (see `flags` in src/data/legal.ts). */ @@ -179,7 +180,7 @@ const facts = [ { label: "Where it runs", value: - "Only on web.toddleapp.com. It has no access to any other site. From version 0.9.0 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 part of the browser, drawn inside closed shadow roots that no script on Toddle's page can read or click into.", + "Only on web.toddleapp.com. It has no access to any other site. 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 part of the browser, drawn inside closed shadow roots that no script on Toddle's page can read or click into.", }, { label: "What it reads", @@ -199,7 +200,7 @@ const facts = [ { label: "Its two outside connections", value: - `The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licenses.nyuchi.com (licences.nyuchi.dev, the same server, in version ${extensionVersions.current}, on the store today) for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.`, + `The Send feedback form, only when you press Send: what you typed and the version number, to our feedback form, processed by Formspree. And, only while a licence is entered, at most one request a day to licenses.nyuchi.com (versions before ${extensionVersions.current} asked licences.nyuchi.dev, the same server) for a signed list of cancelled keys' fingerprints, which sends no key, no identifiers and no Toddle data; if it fails, the last result stands.`, }, { label: "What it stores", @@ -233,7 +234,7 @@ const schema = [ publisher: { "@id": "https://learning.nyuchi.com/#organization" }, featureList: [ "Expands every Toddle assessment tool into per-criterion columns", - "Conceals student flags across Toddle with one free switch, for a shared screen; from version 0.9.0 never hides Toddle's own flags", + "Conceals student flags across Toddle with one free switch, for a shared screen; never hides Toddle's own flags", "CSV export of results and submission status", "A My classes filter on the Toddle home page", "A student sidebar anywhere a student appears in Toddle: photo, grade, email, contacts, where they are now, today's timetable and attendance", @@ -382,10 +383,10 @@ const schema = [ room should not be reading over your shoulder.

    - From version {extensionVersions.next}, the extension no longer hides Toddle's own + The extension doesn't hide Toddle's own flags: they stay where Toddle puts them, with their links and documents in Toddle's student popover. Concealing - them is your choice: a new install starts with flags shown, and if you are - updating from 0.8.2, where flags were hidden by default, or you had hidden them + them is your choice: a new install starts with flags shown, and if you + updated from {extensionVersions.previous}, where flags were hidden by default, or you had hidden them yourself, they stay concealed until you show them. {flags.reveal}

    @@ -418,8 +419,7 @@ const schema = [ as a substitute for your school's safeguarding practice.

    - This describes version {extensionVersions.next}, the next release on the - Chrome Web Store. {flags.before} + This describes version {extensionVersions.current}, on the Chrome Web Store.

    @@ -513,8 +513,8 @@ const schema = [ it to staff; each teacher pastes it in once, from the welcome page or the toolbar menu's Enter a licence key. If your network filters outbound traffic, allow {toddleExtension.licenceHost} - (and licences.nyuchi.dev, which version - {extensionVersions.current}, on the store today, asks) so the daily check for cancelled keys can run. If it is blocked, licences keep + (and licences.nyuchi.dev, which versions + before {extensionVersions.current} asked, for any computer Chrome has not yet updated) so the daily check for cancelled keys can run. If it is blocked, licences keep working on the last result.

    @@ -592,7 +592,7 @@ const schema = [ Checkout is through Buy Me a Coffee, and your licence key is emailed to you automatically, usually within a minute of paying. It is checked on your own machine, so activating it contacts nothing and works offline — and one key covers your laptop and your classroom - desktop. From version {extensionVersions.next} it follows your Chrome account, so you + desktop. It follows your Chrome account, so you enter it once; Chrome's sync carries it, and it is never sent to us. While a key is entered, the extension checks at most once a day for cancelled keys, sending nothing about you.

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