From 2357670b69b8483bac77dd096617f390bc76b4fa Mon Sep 17 00:00:00 2001 From: Sif Aldin Abbas Date: Mon, 28 Sep 2026 13:12:55 +0200 Subject: [PATCH 1/4] PIQ-5877: fail the build when the package carries a company name Scans what `npm pack` would ship rather than the working tree, since `files` limits the tarball to dist and index.d.ts: scanning the tree would fail on CHANGES.md's GitHub links for no reason. Runs before `npm publish` and on pull requests. CI publishes on every push to master, and a name that reaches npm cannot be withdrawn, only superseded. Passes on master today, which PIQ-5878 made true. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build_and_publish.yaml | 4 ++ package.json | 3 +- scripts/check-brand-package.mjs | 70 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 scripts/check-brand-package.mjs diff --git a/.github/workflows/build_and_publish.yaml b/.github/workflows/build_and_publish.yaml index 234eb14..ab116be 100644 --- a/.github/workflows/build_and_publish.yaml +++ b/.github/workflows/build_and_publish.yaml @@ -28,6 +28,10 @@ jobs: - name: Build the project run: npm run build + # Runs on pull requests too, so a branded name is caught before it can be merged and published. + - name: Assert the package carries no company name + run: node scripts/check-brand-package.mjs + - name: Publish to npm if: github.event_name == 'push' && github.ref == 'refs/heads/master' run: npm publish diff --git a/package.json b/package.json index 2330d7a..259782d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "build": "rimraf dist && npm run eslint && npm run build:cjs && npm run build:esm && npm run uglify", "build:cjs": "babel src --out-dir dist --env-name cjs", "uglify": "recursive-uglifyjs dist", - "build:esm": "babel src --out-dir dist/esm --extensions '.js' --env-name esm" + "build:esm": "babel src --out-dir dist/esm --extensions '.js' --env-name esm", + "check:brand": "node scripts/check-brand-package.mjs" }, "devDependencies": { "@babel/cli": "^7.26.4", diff --git a/scripts/check-brand-package.mjs b/scripts/check-brand-package.mjs new file mode 100644 index 0000000..1c6c3cd --- /dev/null +++ b/scripts/check-brand-package.mjs @@ -0,0 +1,70 @@ +/* + Asserts the published package carries no company name (PIQ-5877). + + Scans what `npm pack` would ship rather than the working tree: `files` limits the tarball to dist + and index.d.ts, so CHANGES.md and .github never ship and scanning the tree would fail on their + GitHub links for no reason. CI publishes on every push to master, so this runs before `npm publish` + and on pull requests: a name that reaches npm cannot be withdrawn, only superseded. + + Deliberately not configurable from the environment. A guard that can be switched off where it runs + is not a guard. +*/ +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; + +// Former and current company names, plus the two paymentiq.io tenants. +const FORBIDDEN = ['paymentiq', 'worldline', 'devcode', 'bambora', 'casumo', 'mrgreen']; + +// Word characters only, so joining them into an alternation cannot introduce a metacharacter. +const malformed = FORBIDDEN.filter((name) => !/^\w+$/.test(name)); +if (malformed.length) { + console.error(`FORBIDDEN entries must be word characters only: ${malformed.join(', ')}`); + process.exit(2); +} + +let files; +try { + // --dry-run writes nothing; the JSON lists exactly what a real pack would include. + const out = execFileSync('npm', ['pack', '--dry-run', '--json'], { encoding: 'utf-8' }); + files = JSON.parse(out)[0].files.map((f) => f.path); +} catch (error) { + console.error(`Could not read the package contents: ${error.message}`); + process.exit(2); +} + +if (!files.length) { + console.error('npm pack listed no files, which cannot be right'); + process.exit(2); +} + +const pattern = new RegExp(FORBIDDEN.join('|'), 'gi'); +const failures = []; + +for (const file of files) { + let hits; + try { + hits = readFileSync(file, 'utf-8').match(pattern); + } catch (error) { + // A file that ships but cannot be read is a hole in the guard, not something to skip. + console.error(`Could not read ${file}: ${error.message}`); + process.exit(2); + } + if (hits) failures.push({ file, hits }); +} + +if (failures.length) { + console.error(`The published package carries a company name, in ${failures.length} file(s):\n`); + for (const { file, hits } of failures) { + const counts = new Map(); + for (const hit of hits) { + const name = hit.toLowerCase(); + counts.set(name, (counts.get(name) ?? 0) + 1); + } + const summary = [...counts].map(([name, n]) => `${name} x${n}`).join(', '); + console.error(` ${file}: ${summary}`); + } + console.error(`\nScanned ${files.length} packed file(s) for: ${FORBIDDEN.join(', ')}`); + process.exit(1); +} + +console.log(`No company name in any of the ${files.length} packed file(s).`); From 124b9fcdf4c41ae74549a3cb3d4530c55e4d2364 Mon Sep 17 00:00:00 2001 From: Sif Aldin Abbas Date: Tue, 29 Sep 2026 13:39:42 +0200 Subject: [PATCH 2/4] PIQ-5877: scan packed paths and test the guard Copilot review on this PR. **The packed path is scanned as well as the contents.** A file published as `dist/paymentiq.js` carries the name whatever is inside it, and it becomes the consumer's import path. Relative, as npm lists it, so the checkout directory's own path cannot trip it. **The guard now has its own regression cases**, which is AC 1: 8 of them, covering a clean package, each forbidden name, a name in the packed path, case insensitivity, and the pair that proves the guard reads `npm pack` rather than the tree, since `files` is what decides what ships. Each fixture is a real package directory the guard runs in, because a fixture that only wrote files would not exercise `files` at all. They run in CI ahead of the guard, on pull requests as well as pushes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build_and_publish.yaml | 4 ++ package.json | 3 +- scripts/check-brand-package.mjs | 10 ++- scripts/check-brand-package.test.mjs | 88 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 scripts/check-brand-package.test.mjs diff --git a/.github/workflows/build_and_publish.yaml b/.github/workflows/build_and_publish.yaml index ab116be..470003f 100644 --- a/.github/workflows/build_and_publish.yaml +++ b/.github/workflows/build_and_publish.yaml @@ -28,6 +28,10 @@ jobs: - name: Build the project run: npm run build + # The guard's own regression cases, so a refactor that silently stops catching names fails here. + - name: Test the brand guard + run: node scripts/check-brand-package.test.mjs + # Runs on pull requests too, so a branded name is caught before it can be merged and published. - name: Assert the package carries no company name run: node scripts/check-brand-package.mjs diff --git a/package.json b/package.json index 259782d..ac07d92 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "build:cjs": "babel src --out-dir dist --env-name cjs", "uglify": "recursive-uglifyjs dist", "build:esm": "babel src --out-dir dist/esm --extensions '.js' --env-name esm", - "check:brand": "node scripts/check-brand-package.mjs" + "check:brand": "node scripts/check-brand-package.mjs", + "check:brand:test": "node scripts/check-brand-package.test.mjs" }, "devDependencies": { "@babel/cli": "^7.26.4", diff --git a/scripts/check-brand-package.mjs b/scripts/check-brand-package.mjs index 1c6c3cd..6451d6d 100644 --- a/scripts/check-brand-package.mjs +++ b/scripts/check-brand-package.mjs @@ -41,15 +41,19 @@ const pattern = new RegExp(FORBIDDEN.join('|'), 'gi'); const failures = []; for (const file of files) { - let hits; + let contents; try { - hits = readFileSync(file, 'utf-8').match(pattern); + contents = readFileSync(file, 'utf-8'); } catch (error) { // A file that ships but cannot be read is a hole in the guard, not something to skip. console.error(`Could not read ${file}: ${error.message}`); process.exit(2); } - if (hits) failures.push({ file, hits }); + // The packed path counts as well as the contents: it becomes the consumer's import path, so + // `dist/paymentiq.js` carries the name whatever the file says. Relative, as npm lists it, since + // the absolute path runs through a checkout directory nobody chose. + const hits = [...(file.match(pattern) ?? []), ...(contents.match(pattern) ?? [])]; + if (hits.length) failures.push({ file, hits }); } if (failures.length) { diff --git a/scripts/check-brand-package.test.mjs b/scripts/check-brand-package.test.mjs new file mode 100644 index 0000000..54aaa00 --- /dev/null +++ b/scripts/check-brand-package.test.mjs @@ -0,0 +1,88 @@ +/* + Proves the guard fails a seeded regression (PIQ-5877, AC 1). + + Usage: node scripts/check-brand-package.test.mjs + + Each fixture is a real package directory the guard runs in, because the guard reads `npm pack` + rather than the tree: a fixture that only wrote files would not exercise the thing that decides + what ships, which is `files` in package.json. + + Runs the real script and asserts its exit code, since that is what the CI step acts on. + + No test runner, deliberately. `npm test` here is a placeholder, and adding a framework to assert + exit codes would be more moving parts than the thing it checks. +*/ +import { execFileSync } from 'child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const guard = resolve(dirname(fileURLToPath(import.meta.url)), 'check-brand-package.mjs'); + +/** The guard's exit code in a fixture package: 0 clean, 1 a name found, 2 misconfigured. */ +const run = (dir) => { + try { + execFileSync('node', [guard], { cwd: dir, encoding: 'utf-8', stdio: 'pipe' }); + return 0; + } catch (error) { + return error.status; + } +}; + +const fixture = (files, pkg = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'brand-guard-')); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0', ...pkg }, null, 2), + ); + for (const [name, body] of Object.entries(files)) { + mkdirSync(dirname(join(dir, name)), { recursive: true }); + writeFileSync(join(dir, name), body); + } + return dir; +}; + +const SHIPS_DIST = { files: ['dist'] }; + +const cases = [ + ['a clean package passes', { 'dist/index.js': 'var a=1' }, SHIPS_DIST, 0], + ['a company name in a shipped file fails', { 'dist/index.js': 'var a="paymentiq"' }, SHIPS_DIST, 1], + ['a former company name fails', { 'dist/index.js': '/* bambora */' }, SHIPS_DIST, 1], + ['a tenant name fails', { 'dist/index.js': 'mrgreen' }, SHIPS_DIST, 1], + // Copilot raised this: the packed path becomes the consumer's import path. + ['a company name in the packed path fails', { 'dist/paymentiq.js': 'var a=1' }, SHIPS_DIST, 1], + ['case is ignored', { 'dist/index.js': 'PaymentIQ' }, SHIPS_DIST, 1], + // The point of scanning `npm pack` rather than the tree: `files` decides what ships. + [ + 'a name in a file that does not ship passes', + { 'dist/index.js': 'var a=1', 'CHANGES.md': 'github.com/devcode-git/x' }, + SHIPS_DIST, + 0, + ], + [ + 'the same name fails once that file ships', + { 'dist/index.js': 'var a=1', 'CHANGES.md': 'github.com/devcode-git/x' }, + { files: ['dist', 'CHANGES.md'] }, + 1, + ], +]; + +let failed = 0; +for (const [name, files, pkg, expected] of cases) { + const dir = fixture(files, pkg); + const actual = run(dir); + rmSync(dir, { recursive: true, force: true }); + if (actual === expected) { + console.log(` ok ${name}`); + } else { + console.error(` FAIL ${name}: expected exit ${expected}, got ${actual}`); + failed += 1; + } +} + +if (failed) { + console.error(`\n${failed} case(s) failed.`); + process.exit(1); +} +console.log(`\nAll ${cases.length} cases passed.`); From d15c3ee8993c4ab0380a2e2c19175969bd193c4c Mon Sep 17 00:00:00 2001 From: Sif Aldin Abbas Date: Tue, 29 Sep 2026 15:59:57 +0200 Subject: [PATCH 3/4] PIQ-5877: release as 1.3.4 CI publishes on every push to master, and package.json still said 1.3.3, which is already on npm. `npm publish` would have failed on the duplicate version and taken the master build with it. The bump is warranted rather than a formality: package.json always ships, and this branch adds two scripts to it, so the tarball does differ. `dist` and `index.d.ts` are byte-identical to 1.3.3, which the changelog entry says. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGES.md | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c9a51a5..71e3e78 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +### 1.3.4 +- Build guard: the published package is scanned for company names before `npm publish`, and the guard has its own regression cases. No change to shipped code; `dist` and `index.d.ts` are byte-identical to 1.3.3 + ### 1.3.3 - Origin validation now accepts messages only from the origin of the configured `hostedfieldsurl`. The two built-in fallback origins are removed, so `hostedfieldsurl` must point at the host the iframes actually load from - Remove repository, author and brand keywords from package metadata, and publish only `dist` and `index.d.ts` diff --git a/package-lock.json b/package-lock.json index 785bda1..6bb2c67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.4", "license": "ISC", "dependencies": { "@babel/runtime": "^7.27.0" diff --git a/package.json b/package.json index ac07d92..0b68221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.4", "description": "Hosted fields sdk", "main": "dist/index.js", "types": "index.d.ts", From 33bf76b58111d7dfab7dce7a8061b65c601fe606 Mon Sep 17 00:00:00 2001 From: Sif Aldin Abbas Date: Tue, 29 Sep 2026 16:16:08 +0200 Subject: [PATCH 4/4] PIQ-5877: catch the spaced and hyphenated spellings too Ross on this PR: the pattern matched each name only as one unbroken word, so `Payment IQ`, `Mr Green` and `payment-iq` all passed. His example is in this repo already, `// The payment iq mid` in src/sdk/api/index.js, kept out of dist only because uglify strips comments. README.md and index.d.ts ship unminified, so the same spelling in either would have been published. Each name is now stored as its parts and joined with `[\s_.-]*`, so any number of spaces, underscores, dots or hyphens between them still matches. The word-characters check moved to the parts. Five regression cases added, 13 total: spaced, hyphenated and dotted spellings, a spaced tenant name, and a spaced name in the packed path. Releases 1.3.5. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGES.md | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- scripts/check-brand-package.mjs | 31 +++++++++++++++++++++------- scripts/check-brand-package.test.mjs | 6 ++++++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 71e3e78..6b6e349 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +### 1.3.5 +- Build guard: also catches the spaced and hyphenated spellings of each company name, for example `Payment IQ` or `payment-iq` + ### 1.3.4 - Build guard: the published package is scanned for company names before `npm publish`, and the guard has its own regression cases. No change to shipped code; `dist` and `index.d.ts` are byte-identical to 1.3.3 diff --git a/package-lock.json b/package-lock.json index 6bb2c67..87fea1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hosted-fields-sdk", - "version": "1.3.4", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hosted-fields-sdk", - "version": "1.3.4", + "version": "1.3.5", "license": "ISC", "dependencies": { "@babel/runtime": "^7.27.0" diff --git a/package.json b/package.json index 0b68221..66151cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hosted-fields-sdk", - "version": "1.3.4", + "version": "1.3.5", "description": "Hosted fields sdk", "main": "dist/index.js", "types": "index.d.ts", diff --git a/scripts/check-brand-package.mjs b/scripts/check-brand-package.mjs index 6451d6d..4cd5925 100644 --- a/scripts/check-brand-package.mjs +++ b/scripts/check-brand-package.mjs @@ -12,16 +12,33 @@ import { execFileSync } from 'child_process'; import { readFileSync } from 'fs'; -// Former and current company names, plus the two paymentiq.io tenants. -const FORBIDDEN = ['paymentiq', 'worldline', 'devcode', 'bambora', 'casumo', 'mrgreen']; +/* + Former and current company names, plus the two paymentiq.io tenants, each split where a human would + space it. Matching the closed-up spelling alone lets the prose form through: `Payment IQ` in a + README, `Mr Green` in a comment, `payment-iq` in a filename. The repo already writes it both ways, + for example `// The payment iq mid` in src/sdk/api/index.js. +*/ +const FORBIDDEN = [ + ['payment', 'iq'], + ['world', 'line'], + ['dev', 'code'], + ['bambora'], + ['casumo'], + ['mr', 'green'], +]; -// Word characters only, so joining them into an alternation cannot introduce a metacharacter. -const malformed = FORBIDDEN.filter((name) => !/^\w+$/.test(name)); +// Whitespace, underscore, dot or hyphen between the parts, in any number, including none. +const SEPARATOR = '[\\s_.-]*'; + +// Word characters only, so joining the parts into an alternation cannot introduce a metacharacter. +const malformed = FORBIDDEN.flat().filter((part) => !/^\w+$/.test(part)); if (malformed.length) { - console.error(`FORBIDDEN entries must be word characters only: ${malformed.join(', ')}`); + console.error(`FORBIDDEN parts must be word characters only: ${malformed.join(', ')}`); process.exit(2); } +const names = FORBIDDEN.map((parts) => parts.join('')); + let files; try { // --dry-run writes nothing; the JSON lists exactly what a real pack would include. @@ -37,7 +54,7 @@ if (!files.length) { process.exit(2); } -const pattern = new RegExp(FORBIDDEN.join('|'), 'gi'); +const pattern = new RegExp(FORBIDDEN.map((parts) => parts.join(SEPARATOR)).join('|'), 'gi'); const failures = []; for (const file of files) { @@ -67,7 +84,7 @@ if (failures.length) { const summary = [...counts].map(([name, n]) => `${name} x${n}`).join(', '); console.error(` ${file}: ${summary}`); } - console.error(`\nScanned ${files.length} packed file(s) for: ${FORBIDDEN.join(', ')}`); + console.error(`\nScanned ${files.length} packed file(s) for: ${names.join(', ')}, spaced or hyphenated too`); process.exit(1); } diff --git a/scripts/check-brand-package.test.mjs b/scripts/check-brand-package.test.mjs index 54aaa00..ce5a9fb 100644 --- a/scripts/check-brand-package.test.mjs +++ b/scripts/check-brand-package.test.mjs @@ -53,6 +53,12 @@ const cases = [ // Copilot raised this: the packed path becomes the consumer's import path. ['a company name in the packed path fails', { 'dist/paymentiq.js': 'var a=1' }, SHIPS_DIST, 1], ['case is ignored', { 'dist/index.js': 'PaymentIQ' }, SHIPS_DIST, 1], + // Ross on #48: matching only the closed-up spelling let the prose form through. + ['a spaced company name fails', { 'dist/index.js': '// The payment iq mid' }, SHIPS_DIST, 1], + ['a hyphenated company name fails', { 'dist/index.js': 'payment-iq' }, SHIPS_DIST, 1], + ['a spaced tenant name fails', { 'dist/index.js': 'Powered by Mr Green' }, SHIPS_DIST, 1], + ['a dotted company name fails', { 'dist/index.js': 'world.line' }, SHIPS_DIST, 1], + ['a spaced name in the packed path fails', { 'dist/payment-iq.js': 'var a=1' }, SHIPS_DIST, 1], // The point of scanning `npm pack` rather than the tree: `files` decides what ships. [ 'a name in a file that does not ship passes',