diff --git a/.github/workflows/build_and_publish.yaml b/.github/workflows/build_and_publish.yaml index 234eb14..470003f 100644 --- a/.github/workflows/build_and_publish.yaml +++ b/.github/workflows/build_and_publish.yaml @@ -28,6 +28,14 @@ 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 + - name: Publish to npm if: github.event_name == 'push' && github.ref == 'refs/heads/master' run: npm publish diff --git a/CHANGES.md b/CHANGES.md index c9a51a5..6b6e349 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,9 @@ +### 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 + ### 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..87fea1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.5", "license": "ISC", "dependencies": { "@babel/runtime": "^7.27.0" diff --git a/package.json b/package.json index 2330d7a..66151cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hosted-fields-sdk", - "version": "1.3.3", + "version": "1.3.5", "description": "Hosted fields sdk", "main": "dist/index.js", "types": "index.d.ts", @@ -24,7 +24,9 @@ "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", + "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 new file mode 100644 index 0000000..4cd5925 --- /dev/null +++ b/scripts/check-brand-package.mjs @@ -0,0 +1,91 @@ +/* + 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, 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'], +]; + +// 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 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. + 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.map((parts) => parts.join(SEPARATOR)).join('|'), 'gi'); +const failures = []; + +for (const file of files) { + let contents; + try { + 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); + } + // 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) { + 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: ${names.join(', ')}, spaced or hyphenated too`); + process.exit(1); +} + +console.log(`No company name in any of the ${files.length} packed file(s).`); diff --git a/scripts/check-brand-package.test.mjs b/scripts/check-brand-package.test.mjs new file mode 100644 index 0000000..ce5a9fb --- /dev/null +++ b/scripts/check-brand-package.test.mjs @@ -0,0 +1,94 @@ +/* + 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], + // 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', + { '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.`);