All checks were successful
PR checks / checks (pull_request) Successful in 55s
PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.
Four decisions taken by the org lead before either page was written, recorded in
§9 under "How phase 6 built the legal pages":
D30 DNS-only records, so the reverse proxy on the host keeps the only access
log. Described qualitatively — the retention belongs to the proxy, and a
policy that quotes a number the deployment does not enforce is worse than
one that does not.
D31 Eighteen or older. Above the children's-consent threshold everywhere in the
EEA, so consent works with no parental-consent machinery this form could not
honestly operate. Four surfaces render it from src/data/legal.mjs, and every
one says plainly that nothing verifies it.
D32 No governing-law clause. Nothing of value is contracted for here.
D33 PLAY_DATA_SAFETY.md is generated from src/data/collection.mjs and checked in
CI, so the published policy and the answers given to Google cannot drift.
/privacy is three separately-scoped sections because "we" means three different
parties: this site (one form, no cookies, no third-party requests), the Android app
(we operate no server it talks to — the rows are what the DEVICE holds), and a
self-hosted deployment (the operator is the controller, not us). Every row names the
file it was read out of, because a policy is the document most likely to be written
from a template and least likely to be re-read against the software.
/terms governs only what we run: this site, the beta list, and the APK we publish.
The software is governed by its licence, and a community's deployment by that
community — a terms page claiming authority over every install of a GPL program is
the thing a generated template gets wrong.
Also here:
- the age clause changed CONSENT_TEXT, so CONSENT_VERSION gained a suffix; rows
written from now on carry the new sentence and older rows keep theirs
- PLANNED_ROUTES is now empty — these were its last two entries, and its reverse
check is what forced the deletion; the list stays for phases 7 and 8
- test/legal.test.mjs asserts the structural promises no build check can see,
including that every mapped Play row still answers "not collected, not shared"
- --check normalises line endings: the repo has no .gitattributes and Windows
checkouts are CRLF, so a byte comparison would fail for every Windows developer
while passing in CI
Verified: npm run verify green end to end (tokens, brand, data safety, astro check,
36 tests, build, 214 links, 19 facts), both pages walked in a browser, and neither
overflows at 390px. One defect the checks could not see and a look could: the
retention line was being pushed to the foot of the tallest card in its row, opening
a void in the middle of the short ones.
Co-Authored-By: Claude <noreply@anthropic.com>
154 lines
6.3 KiB
JavaScript
154 lines
6.3 KiB
JavaScript
/**
|
|
* The legal pages' data, tested where a mistake would be invisible. PLAN.md §9, phase 6.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* WHAT IS ACTUALLY AT RISK HERE
|
|
* ---------------------------------------------------------------------------------------
|
|
* A privacy policy is prose, and prose is not testable. What IS testable is the small set
|
|
* of structural promises the page and the Play declaration both rest on, every one of
|
|
* which fails silently:
|
|
*
|
|
* - a row with no retention line renders a card with an empty "How long" — which reads
|
|
* as "we keep this forever" or "we keep nothing", depending on the reader;
|
|
* - an `app`-scoped row with no Play mapping means a question on the console form gets
|
|
* answered from memory, which is the exact failure D33 exists to prevent;
|
|
* - a row that claims we collect or share something contradicts the premise the whole
|
|
* page rests on, and would be a real disclosure defect rather than a typo;
|
|
* - the consent sentence and /terms stating different minimum ages, which is the kind of
|
|
* inconsistency a reviewer finds and a developer never does.
|
|
*
|
|
* None of that shows up in a build, a type check or a link check: the page renders
|
|
* beautifully with an empty cell and a wrong number in it.
|
|
*
|
|
* The generated Play document is checked by `scripts/playDataSafety.mjs --check` rather
|
|
* than here — a generator's output is a build artefact, and comparing it in two places
|
|
* means fixing it in two places.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
|
|
import {
|
|
assertScopeNonEmpty,
|
|
collected,
|
|
collectedIn,
|
|
playRows,
|
|
} from '../src/data/collection.mjs';
|
|
import { legal } from '../src/data/legal.mjs';
|
|
import { CONSENT_TEXT, CONSENT_VERSION, requirements } from '../src/data/beta.mjs';
|
|
|
|
const SCOPES = ['site', 'app', 'deployment'];
|
|
|
|
describe('the collection inventory', () => {
|
|
it('has a row in every scope /privacy renders', () => {
|
|
for (const scope of SCOPES) {
|
|
assert.doesNotThrow(() => assertScopeNonEmpty(scope), `scope "${scope}" is empty`);
|
|
}
|
|
});
|
|
|
|
it('uses only the three scopes the page knows how to render', () => {
|
|
for (const row of collected) {
|
|
assert.ok(SCOPES.includes(row.scope), `${row.id} has unknown scope "${row.scope}"`);
|
|
}
|
|
});
|
|
|
|
it('gives every row a unique id', () => {
|
|
const ids = collected.map((row) => row.id);
|
|
assert.equal(new Set(ids).size, ids.length, 'duplicate id in collection.mjs');
|
|
});
|
|
|
|
it('gives every row a retention summary and a source', () => {
|
|
for (const row of collected) {
|
|
assert.ok(row.retention?.summary?.trim(), `${row.id} has no retention summary`);
|
|
assert.ok(row.source?.trim(), `${row.id} does not name the file it was read from`);
|
|
assert.ok(row.title?.trim() && row.body?.trim(), `${row.id} is missing prose`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the Play Data Safety mapping', () => {
|
|
it('maps every app-scoped row', () => {
|
|
assert.doesNotThrow(() => playRows());
|
|
assert.equal(playRows().length, collectedIn('app').length);
|
|
});
|
|
|
|
it('answers every mapped question completely', () => {
|
|
for (const row of playRows()) {
|
|
const { category, type, answer, because, collected: isCollected, shared } = row.play;
|
|
assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`);
|
|
assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`);
|
|
assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`);
|
|
assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`);
|
|
}
|
|
});
|
|
|
|
/*
|
|
* The one assertion here that is about the product rather than the shape of the data.
|
|
*
|
|
* "We operate no server the app talks to" is the premise of /privacy section 2, of the
|
|
* generated declaration, and of the argument for why the app needs no account with us.
|
|
* If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted
|
|
* directory of deployments — the honest change is a `collected: true` row AND a rewrite
|
|
* of the page's second section. This test makes the first impossible without noticing
|
|
* the second, by failing with the reason rather than the diff.
|
|
*/
|
|
it('holds the premise the whole section rests on', () => {
|
|
for (const row of playRows()) {
|
|
assert.equal(
|
|
row.play.collected,
|
|
false,
|
|
`${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` +
|
|
'that we operate no server the app talks to — has changed, and the page has to ' +
|
|
'change with it rather than gaining a row that contradicts its own lede.'
|
|
);
|
|
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the minimum age', () => {
|
|
it('is stated in the consent sentence the row records', () => {
|
|
assert.match(
|
|
CONSENT_TEXT,
|
|
new RegExp(`\\b${legal.minimumAge}\\b`),
|
|
'the consent text does not state the minimum age'
|
|
);
|
|
});
|
|
|
|
it('is stated in the eligibility list', () => {
|
|
const ages = requirements.filter((entry) =>
|
|
new RegExp(`\\b${legal.minimumAge}\\b`).test(entry.title)
|
|
);
|
|
assert.equal(ages.length, 1, 'the beta requirements should name the age exactly once');
|
|
});
|
|
|
|
/*
|
|
* Changing the wording without changing the label would leave two different sentences
|
|
* sharing one version in the exported CSV, which is the only thing that label is for.
|
|
*/
|
|
it('was accompanied by a consent version bump', () => {
|
|
assert.notEqual(
|
|
CONSENT_VERSION,
|
|
'2026-08-24',
|
|
'the age clause changed CONSENT_TEXT; CONSENT_VERSION must not still be the label ' +
|
|
'the pre-age wording was written under'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('the published contact route', () => {
|
|
/*
|
|
* D13 in test form. `checkFacts.mjs` enforces this over the whole of `src/` and
|
|
* `scripts/`, and it needs a network token to run — so it is skipped by anybody working
|
|
* offline, on the two files most likely to want to type an address into.
|
|
*/
|
|
it('is not baked into the legal data', () => {
|
|
const text = JSON.stringify({ collected, legal });
|
|
assert.doesNotMatch(
|
|
text,
|
|
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
|
'an email address is hard-coded in the legal data — read brand.contactEmail instead'
|
|
);
|
|
});
|
|
});
|