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
8 changes: 8 additions & 0 deletions .github/workflows/build_and_publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions scripts/check-brand-package.mjs
Original file line number Diff line number Diff line change
@@ -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).`);
94 changes: 94 additions & 0 deletions scripts/check-brand-package.test.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
Loading