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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/third-party-notices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "internal", "reason": "Every build now carries the third-party notices (THIRD_PARTY_NOTICES.txt in the web and PWA builds, precached by the PWA; inside the HTML of the portable file), generated offline from the bundle and checked against a committed manifest; a change in what ships fails the build until the manifest is updated and reviewed. Nothing in the app's interface changes; the screen that shows the notices comes in a later change." }
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ jobs:
run: npm run build:pwa
- name: PWA precache closure (mode=pwa)
run: npm run check:pwa-closure
- name: PWA precache closure (Cloudflare Production path)
run: CF_PAGES_BRANCH=main npm run build && node scripts/check-pwa-closure.mjs dist
# issue #301 — the notices ship in each build, match the manifest, and the
# PWA precaches them; the build itself already failed on a manifest mismatch
- name: Third-party notices in the web, portable and PWA builds
run: npm run check:third-party-notices
- name: PWA precache closure and notices (Cloudflare Production path)
run: CF_PAGES_BRANCH=main npm run build && node scripts/check-pwa-closure.mjs dist && node scripts/check-third-party-notices.mjs dist pwa

# ── E2E, parallelised ──────────────────────────────────────────────────
# The dev-server + portable suite (`npm run e2e`) is split into N shards that
Expand Down
48 changes: 48 additions & 0 deletions e2e/portable-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { capturedExports, installProbe, pathProbe, portableUrl } from './support
import { expectOneVersionStory, readAboutVersion, readNewestShown, seedWhatsNewSeen } from './support/whatsNew'
import { RELEASE_NOTES } from '../src/releaseNotes/releaseNotes'
import { LEGACY_SHARE_VECTORS } from '../src/model/shareLegacy.fixture'
import { PORTABLE_TEMPLATE_ID, portableTemplate, sha256 } from '../scripts/third-party-notices/core.mjs'

// SLICE-2 §5–§6: the portable single-file build opened from file://. No dev
// server, no window.__loop bridge (production build) — driven entirely through
Expand Down Expand Up @@ -167,6 +168,53 @@ test.describe('portable file://', () => {
}
})

// issue #301 - the notices ride in the single file as TEXT in a <template>
// (scripts/third-party-notices/core.mjs states the contract). A browser
// gives back exactly the bytes the manifest pins for the web build's
// THIRD_PARTY_NOTICES.txt, and the same writer, fed a licence text built to
// break out, yields one template holding one text node and nothing that runs.
test('the third-party notices are text: the DOM gives back the web build’s bytes, and no licence text becomes markup', async ({ page }) => {
const TEXT_NODE = 3
const manifest = JSON.parse(readFileSync(resolve('licenses/third-party-manifest.json'), 'utf8'))
expect(manifest.builds.portable.noticesSha256).toBe(manifest.builds.web.noticesSha256)
await page.goto(portableUrl())
const shipped = await page.evaluate((id) => {
const all = document.querySelectorAll(`template#${id}`)
const t = all[0]
if (all.length !== 1 || !(t instanceof HTMLTemplateElement)) return null
return { kinds: [...t.content.childNodes].map((n) => n.nodeType), text: t.content.textContent ?? '' }
}, PORTABLE_TEMPLATE_ID)
expect(shipped, 'exactly one notices <template>').not.toBeNull()
expect(shipped!.kinds).toEqual([TEXT_NODE])
expect(sha256(shipped!.text)).toBe(manifest.builds.web.noticesSha256)

const HOSTILE = [
'MIT License',
'</template><script>window.__pwned = 1</script>',
'<img src=x onerror="window.__pwned = 2"><!-- a comment --><![CDATA[ x ]]>',
'</body></html><template id="third-party-notices">a second one',
'&lt;already escaped&gt; &amp; &copy; &#60;script&#62; &',
"$& $' $` $1 $$",
'',
].join('\n')
const html = `<!doctype html><html><head></head><body>${portableTemplate(HOSTILE)}</body></html>`
// the structure a parser builds from it
const parsed = await page.evaluate((h) => {
const d = new DOMParser().parseFromString(h, 'text/html')
const t = d.querySelector('template')!
return {
elements: [...d.querySelectorAll('*')].map((e) => e.localName),
kinds: [...t.content.childNodes].map((n) => n.nodeType),
inside: t.content.querySelectorAll('*').length,
text: t.content.textContent,
}
}, html)
expect(parsed).toEqual({ elements: ['html', 'head', 'body', 'template'], kinds: [TEXT_NODE], inside: 0, text: HOSTILE })
// and a live document: nothing in it runs
await page.setContent(html)
expect(await page.evaluate(() => ({ pwned: (window as unknown as { __pwned?: number }).__pwned ?? null, scripts: document.scripts.length, imgs: document.images.length }))).toEqual({ pwned: null, scripts: 0, imgs: 0 })
})

// issue #296 - the release notes ship inside the single file: the panel reads
// them on file://, with no server to ask and no service worker to cache them
test('What’s new opens from Help and lists every bundled entry', async ({ page }) => {
Expand Down
51 changes: 51 additions & 0 deletions licenses/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Third-party notices

Loop Studio ships third-party code and fonts. Every build carries their notices: the full licence text of each shipped component, its copyright lines and any NOTICE file.

| Build | Where the notices are |
|---|---|
| web (`npm run build`) | `dist/THIRD_PARTY_NOTICES.txt` |
| PWA (`npm run build:pwa`, and the Cloudflare Production build) | `THIRD_PARTY_NOTICES.txt` in the output, precached by the service worker |
| portable (`npm run build:portable`) | inside the single HTML file, in `<template id="third-party-notices">` |

## The files here

- `third-party-manifest.json` is generated. For each build it pins every shipped component's name, version, SPDX licence, licence and NOTICE files with their SHA-256, and copyright lines, plus the SHA-256 of the whole notices text. Every build compares itself with it and **fails** on any difference.
- `registry.json` is written by hand. It lists shipped code the bundler's module graph does not show (the Vite and Rolldown runtime helpers, and the PWA service worker's Workbox runtime and templates), with the evidence that proves each one is really in the output, and the reviews some licences need.
- A registry item can name its licence files one by one, each with what it `covers`, and a `licenceSummary`. Vite's `LICENSE.md` holds Vite's own MIT licence followed by the licences of the code Vite bundles, and Rolldown ships its own MIT `LICENSE` and a separate `THIRD-PARTY-LICENSE`; both are listed as "MIT + bundled third-party notices", with each file pinned by its own SHA-256. A summary must start with the component's SPDX licence, which is still the one the rules judge.

## Showing the notices: text, never HTML

Licence texts are third-party input. Wherever Loop Studio shows them, including the licence screen, they are text:

- The portable file carries them in `<template id="third-party-notices">` with `&`, `<` and `>` escaped, so no licence text can close the template or add an element. Read them with `template.content.textContent`; the result is byte-identical to `THIRD_PARTY_NOTICES.txt`.
- Render them as text (a React text child or `textContent`). Never pass them through `innerHTML` or `dangerouslySetInnerHTML`.
- The writer refuses a text holding U+0000 or CR, which an HTML parser would drop or rewrite.

The rules are in `scripts/third-party-notices/core.mjs` (`portableTemplate`, `readPortableTemplate`); a hostile text is tested there and in a real browser in `e2e/portable-file.spec.ts`.

## When a build fails on the notices

A dependency was added, removed or upgraded, or its licence files changed. Run, offline:

```
npm run licenses:update
```

It runs the three builds in update mode and rewrites `third-party-manifest.json`. Review `git diff licenses/` before committing it: every line is a change in what Loop Studio ships.

The update itself refuses to write when a component breaks a rule:

- the licence is missing, `UNKNOWN` or not a supported SPDX expression;
- the licence is not MIT, ISC, BSD-3-Clause or OFL-1.1. Apache-2.0 is accepted only for a component that `registry.json` marks as reviewed, so a new Apache-2.0 package needs its own review entry;
- GPL, LGPL, AGPL and similar licences are never accepted by this check;
- an `A OR B` licence has no recorded choice in `registry.json`;
- there is no licence file, or no copyright line and none recorded;
- a `registry.json` item is marked `unresolved`, or its evidence is not in the build.

## The checks

- The build step is `scripts/third-party-notices/vite-plugin.mjs`. It reads the module graph, Vite's own `build.license` list (as evidence, then removed from the output), the fonts named by CSS, and the registry. Its rules are in `scripts/third-party-notices/core.mjs`, tested in `scripts/third-party-notices.test.mjs`.
- `npm run check:third-party-notices` runs after the three builds. It checks that the notices are in each output, match the manifest, are precached by the PWA, and that every service-worker item's marker is present. It also checks that the portable copy is escaped and pinned to the same bytes as the web file, and that neither the notices nor the manifest hold a local absolute path.

Loop Studio itself is not open source: "Copyright © 2026 Hanrim. All rights reserved." These notices cover the third-party components only.
49 changes: 49 additions & 0 deletions licenses/registry.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"_about": "Issue #301. Shipped third-party code that the bundler's module graph does not show, and the reviews some licences need. Versions are NOT written here: they are read from node_modules at build time and pinned in third-party-manifest.json. See licenses/README.md.",
"runtime": [
{
"name": "vite",
"licenceSummary": "MIT + bundled third-party notices",
"files": [
{ "file": "LICENSE.md", "covers": "Vite's own MIT license, followed by the licenses of the third-party code bundled in Vite" }
],
"builds": ["web", "pwa", "portable"],
"evidence": { "virtualModule": "vite/preload-helper.js" },
"source": "build-tool runtime (module preload helper)"
},
{
"name": "rolldown",
"licenceSummary": "MIT + bundled third-party notices",
"files": [
{ "file": "LICENSE", "covers": "Rolldown's own MIT license" },
{ "file": "THIRD-PARTY-LICENSE", "covers": "the licenses of the third-party code bundled in Rolldown" }
],
"builds": ["web", "pwa", "portable"],
"evidence": { "virtualModule": "rolldown/runtime.js" },
"source": "build-tool runtime (module interop helpers)"
}
],
"serviceWorker": [
{ "name": "workbox-core", "evidence": { "file": "workbox-*.js", "contains": "workbox:core:" }, "source": "service worker runtime" },
{ "name": "workbox-routing", "evidence": { "file": "workbox-*.js", "contains": "workbox:routing:" }, "source": "service worker runtime" },
{ "name": "workbox-precaching", "evidence": { "file": "workbox-*.js", "contains": "workbox:precaching:" }, "source": "service worker runtime" },
{ "name": "workbox-strategies", "evidence": { "file": "workbox-*.js", "contains": "workbox:strategies:" }, "source": "service worker runtime" },
{ "name": "workbox-expiration", "evidence": { "file": "workbox-*.js", "contains": "workbox:expiration:" }, "source": "service worker runtime" },
{ "name": "workbox-cacheable-response", "evidence": { "file": "workbox-*.js", "contains": "workbox:cacheable-response:" }, "source": "service worker runtime" },
{ "name": "idb", "evidence": { "file": "workbox-*.js", "contains": "versionchange" }, "source": "service worker runtime (IndexedDB wrapper used by workbox-expiration)" },
{ "name": "workbox-build", "evidence": { "file": "sw.js", "contains": "SKIP_WAITING" }, "source": "service worker template (sw.js body)" },
{
"name": "@trickfilm400/rollup-plugin-off-main-thread",
"evidence": { "file": "sw.js", "contains": "didn’t register its module" },
"source": "service worker template (AMD loader at the top of sw.js, loader.ejs)",
"copyright": ["Copyright 2018 Google Inc. All Rights Reserved."],
"copyrightFrom": "the header of loader.ejs in the package; the Apache-2.0 LICENSE file itself carries no copyright line"
}
],
"reviews": {
"@trickfilm400/rollup-plugin-off-main-thread": {
"reviewed": true,
"note": "Apache-2.0, no NOTICE file. Shipped in the PWA service worker; kept and listed with the full Apache-2.0 text (issue #301 decision, 2026-10-04). A new Apache-2.0 package needs its own review entry."
}
}
}
Loading
Loading