From 4903dd01aa6d9b3a2a1f9318ad5425359bd5da94 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 13:09:19 +0100 Subject: [PATCH 1/5] fix(install): ship headers order data instead of fetching it on install The postinstall script downloaded headers-order.json from an unpinned raw.githack.com URL on every install. Offline, or when the host was down, it exited 1 and broke the consumer's install. Whoever controlled that URL could change the behavior of every new install without a release. It also rewrote src/headers-order.json in place, which under pnpm is a hardlink into the shared content-addressable store. The data file was already committed and published, so the download only added risk. The postinstall is gone and scripts/ is no longer published. scripts/update-headers-order.js refreshes the file at release time: it runs from prerelease, validates the payload has a non-empty header list for chrome, firefox and safari, writes it and stages it so standard-version -a includes it in the release commit. The test suite runs again after the refresh, because CI runs npm test before the release step and would otherwise publish data no test saw. A failed or malformed fetch exits 1 and leaves the shipped file untouched, so a bad upstream fails the release instead of reaching users. The source moves from raw.githack.com to raw.githubusercontent.com, dropping a third-party proxy from the chain. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cron.yml | 2 +- package.json | 7 +++--- scripts/postinstall.js | 26 ---------------------- scripts/update-headers-order.js | 38 +++++++++++++++++++++++++++++++++ test/package.js | 32 +++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 31 deletions(-) delete mode 100644 scripts/postinstall.js create mode 100644 scripts/update-headers-order.js create mode 100644 test/package.js diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 956a6a2..73114dc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -29,7 +29,7 @@ jobs: run: | git config --global user.email ${{ secrets.GIT_EMAIL }} git config --global user.name ${{ secrets.GIT_USERNAME }} - node scripts/postinstall.js --strict + node scripts/update-headers-order.js npm test git add src/headers-order.json git diff-index --quiet HEAD || git commit -m 'build(update): headers order' --no-verify diff --git a/package.json b/package.json index 59d14d5..2ff0777 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "files": [ "hook.js", "index.js", - "scripts", "src" ], "scripts": { @@ -60,14 +59,14 @@ "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true", "coverage": "nyc report --reporter=text-lcov | coveralls", "lint": "standard-markdown README.md && standard", - "postinstall": "node scripts/postinstall.js", "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)", - "prerelease": "npm run contributors", + "prerelease": "npm run contributors && npm run update:headers-order && npm test", "pretest": "npm run lint", "release": "standard-version -a", "release:github": "github-generate-release", "release:tags": "git push --follow-tags origin HEAD:master", - "test": "c8 ava" + "test": "c8 ava", + "update:headers-order": "node scripts/update-headers-order.js && git add src/headers-order.json" }, "license": "MIT", "ava": { diff --git a/scripts/postinstall.js b/scripts/postinstall.js deleted file mode 100644 index ea79d1f..0000000 --- a/scripts/postinstall.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict' - -const { writeFile } = require('fs/promises') - -const URL = - 'https://raw.githack.com/apify/fingerprint-suite/master/packages/header-generator/src/data_files/headers-order.json' - -const isStrict = process.argv.includes('--strict') - -fetch(URL) - .then(res => { - if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) - return res.json() - }) - .then(headersOrder => - writeFile('src/headers-order.json', JSON.stringify(headersOrder, null, 2)) - ) - .catch(error => { - if (isStrict) { - console.error(error) - process.exit(1) - } - console.warn( - `https-tls: couldn't update headers order, using the bundled one (${error.message})` - ) - }) diff --git a/scripts/update-headers-order.js b/scripts/update-headers-order.js new file mode 100644 index 0000000..ab858f2 --- /dev/null +++ b/scripts/update-headers-order.js @@ -0,0 +1,38 @@ +'use strict' + +const { writeFile } = require('fs/promises') +const path = require('path') + +const SOURCE_URL = + 'https://raw.githubusercontent.com/apify/fingerprint-suite/master/packages/header-generator/src/data_files/headers-order.json' + +const DESTINATION = path.join(__dirname, '../src/headers-order.json') + +const REQUIRED_BROWSERS = ['chrome', 'firefox', 'safari'] + +const isHeaderList = value => + Array.isArray(value) && + value.length > 0 && + value.every(header => typeof header === 'string') + +const assertHeadersOrder = headersOrder => { + const invalid = REQUIRED_BROWSERS.filter( + browser => !isHeaderList(headersOrder?.[browser]) + ) + if (invalid.length > 0) { + throw new TypeError(`Unexpected headers order for: ${invalid.join(', ')}`) + } + return headersOrder +} + +const main = async () => { + const response = await fetch(SOURCE_URL) + if (!response.ok) throw new Error(`HTTP ${response.status} for ${SOURCE_URL}`) + const headersOrder = assertHeadersOrder(await response.json()) + await writeFile(DESTINATION, JSON.stringify(headersOrder, null, 2)) +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/test/package.js b/test/package.js new file mode 100644 index 0000000..24693cb --- /dev/null +++ b/test/package.js @@ -0,0 +1,32 @@ +'use strict' + +const { execFileSync } = require('child_process') +const test = require('ava').default +const path = require('path') + +const pkg = require('../package.json') + +const INSTALL_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall'] + +const packedFiles = () => { + const [{ files }] = JSON.parse( + execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: path.join(__dirname, '..'), + encoding: 'utf8' + }) + ) + return files.map(file => file.path) +} + +test('installing runs no scripts', t => { + const installScripts = INSTALL_LIFECYCLE_SCRIPTS.filter( + name => name in pkg.scripts + ) + t.deepEqual(installScripts, []) +}) + +test('the published package ships the headers order data and no release scripts', t => { + const files = packedFiles() + t.true(files.includes('src/headers-order.json')) + t.false(files.some(file => file.startsWith('scripts/'))) +}) From a437b8f07349f95f985c37fe97d8fdfa46f44847 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 13:24:40 +0100 Subject: [PATCH 2/5] fix(scripts): validate every browser entry and write the data atomically Co-Authored-By: Claude Opus 5.5 --- scripts/update-headers-order.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/update-headers-order.js b/scripts/update-headers-order.js index ab858f2..8002c31 100644 --- a/scripts/update-headers-order.js +++ b/scripts/update-headers-order.js @@ -1,6 +1,6 @@ 'use strict' -const { writeFile } = require('fs/promises') +const { writeFile, rename, rm } = require('fs/promises') const path = require('path') const SOURCE_URL = @@ -16,7 +16,10 @@ const isHeaderList = value => value.every(header => typeof header === 'string') const assertHeadersOrder = headersOrder => { - const invalid = REQUIRED_BROWSERS.filter( + const browsers = [ + ...new Set([...REQUIRED_BROWSERS, ...Object.keys(headersOrder ?? {})]) + ] + const invalid = browsers.filter( browser => !isHeaderList(headersOrder?.[browser]) ) if (invalid.length > 0) { @@ -25,11 +28,22 @@ const assertHeadersOrder = headersOrder => { return headersOrder } +const writeAtomically = async (destination, content) => { + const temporary = `${destination}.${process.pid}.tmp` + try { + await writeFile(temporary, content) + await rename(temporary, destination) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + const main = async () => { const response = await fetch(SOURCE_URL) if (!response.ok) throw new Error(`HTTP ${response.status} for ${SOURCE_URL}`) const headersOrder = assertHeadersOrder(await response.json()) - await writeFile(DESTINATION, JSON.stringify(headersOrder, null, 2)) + await writeAtomically(DESTINATION, JSON.stringify(headersOrder, null, 2)) } main().catch(error => { From 7d180f33f735309a64702615caca79d15e9ebba0 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 15:08:12 +0000 Subject: [PATCH 3/5] test(scripts): lock header-order update failures The updater's checks never ran in the suite, so inverting them stayed green. Importing the script no longer fetches, a failed cleanup keeps the original write error, and each new check now has a test that fails when it is inverted. Co-authored-by: Cursor --- scripts/update-headers-order.js | 31 +- test/package.js | 13 +- test/update-headers-order.js | 491 ++++++++++++++++++++++++++++++++ 3 files changed, 521 insertions(+), 14 deletions(-) create mode 100644 test/update-headers-order.js diff --git a/scripts/update-headers-order.js b/scripts/update-headers-order.js index 8002c31..b06d934 100644 --- a/scripts/update-headers-order.js +++ b/scripts/update-headers-order.js @@ -1,6 +1,6 @@ 'use strict' -const { writeFile, rename, rm } = require('fs/promises') +const fs = require('fs/promises') const path = require('path') const SOURCE_URL = @@ -28,25 +28,30 @@ const assertHeadersOrder = headersOrder => { return headersOrder } -const writeAtomically = async (destination, content) => { +const writeAtomically = async (destination, content, io = fs) => { const temporary = `${destination}.${process.pid}.tmp` try { - await writeFile(temporary, content) - await rename(temporary, destination) + await io.writeFile(temporary, content) + await io.rename(temporary, destination) } catch (error) { - await rm(temporary, { force: true }) + // A cleanup failure must not replace the write or rename error. + await io.rm(temporary, { force: true }).catch(() => {}) throw error } } -const main = async () => { - const response = await fetch(SOURCE_URL) - if (!response.ok) throw new Error(`HTTP ${response.status} for ${SOURCE_URL}`) +const main = async ({ destination = DESTINATION, url = SOURCE_URL } = {}) => { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`) const headersOrder = assertHeadersOrder(await response.json()) - await writeAtomically(DESTINATION, JSON.stringify(headersOrder, null, 2)) + await writeAtomically(destination, JSON.stringify(headersOrder, null, 2)) } -main().catch(error => { - console.error(error) - process.exitCode = 1 -}) +if (require.main === module) { + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) +} + +module.exports = { assertHeadersOrder, main, writeAtomically } diff --git a/test/package.js b/test/package.js index 24693cb..980c13e 100644 --- a/test/package.js +++ b/test/package.js @@ -10,7 +10,7 @@ const INSTALL_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall'] const packedFiles = () => { const [{ files }] = JSON.parse( - execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: path.join(__dirname, '..'), encoding: 'utf8' }) @@ -30,3 +30,14 @@ test('the published package ships the headers order data and no release scripts' t.true(files.includes('src/headers-order.json')) t.false(files.some(file => file.startsWith('scripts/'))) }) + +test('release refreshes header order before testing and stages it only after a successful update', t => { + t.is( + pkg.scripts.prerelease, + 'npm run contributors && npm run update:headers-order && npm test' + ) + t.is( + pkg.scripts['update:headers-order'], + 'node scripts/update-headers-order.js && git add src/headers-order.json' + ) +}) diff --git a/test/update-headers-order.js b/test/update-headers-order.js new file mode 100644 index 0000000..0987dde --- /dev/null +++ b/test/update-headers-order.js @@ -0,0 +1,491 @@ +'use strict' + +const { spawnSync } = require('child_process') +const fs = require('fs') +const { mkdir, mkdtemp, readFile, readdir, rm, writeFile } = require('fs/promises') +const http = require('http') +const os = require('os') +const path = require('path') +const { gzipSync } = require('zlib') + +const test = require('ava').default + +const { + assertHeadersOrder, + main, + writeAtomically +} = require('../scripts/update-headers-order') + +const SCRIPT = path.join(__dirname, '../scripts/update-headers-order.js') +const SHIPPED = path.join(__dirname, '../src/headers-order.json') +const SOURCE_URL = + 'https://raw.githubusercontent.com/apify/fingerprint-suite/master/packages/header-generator/src/data_files/headers-order.json' +const ORIGINAL = 'original\n' + +const shipped = require('../src/headers-order.json') + +const validOrder = { + chrome: ['Host', ':method'], + firefox: ['Host'], + safari: ['Accept'], + edge: ['Host'] +} + +const temporaryDirectory = async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'headers-order-')) + return directory +} + +const listen = handler => + new Promise((resolve, reject) => { + const server = http.createServer(handler) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + resolve({ + server, + url: `http://127.0.0.1:${port}` + }) + }) + server.on('error', reject) + }) + +const closeServer = server => + new Promise(resolve => { + server.closeAllConnections?.() + server.close(() => resolve()) + }) + +const withServer = async (handler, run) => { + const { server, url } = await listen(handler) + try { + return await run(url) + } finally { + await closeServer(server) + } +} + +const destinationIn = async directory => { + const destination = path.join(directory, 'headers-order.json') + await writeFile(destination, ORIGINAL) + return destination +} + +const temporaryNames = async directory => + (await readdir(directory)).filter(name => name.endsWith('.tmp')) + +test('the shipped header order is a valid payload', t => { + t.is(assertHeadersOrder(shipped), shipped) + t.true(Object.keys(shipped).includes('edge')) +}) + +test('a payload with only the required browsers is valid', t => { + const headersOrder = { + chrome: ['Host'], + firefox: ['Host'], + safari: ['Accept'] + } + t.is(assertHeadersOrder(headersOrder), headersOrder) +}) + +test('an extra browser with a header list is valid', t => { + t.is(assertHeadersOrder(validOrder), validOrder) +}) + +test('empty and unusual header names are strings, so they stay valid', t => { + const headersOrder = { + chrome: ['', ' ', 'Host\r\nX-Injected: 1', ':method'], + firefox: ['Host'], + safari: ['Accept'] + } + t.is(assertHeadersOrder(headersOrder), headersOrder) +}) + +test('a differently cased browser name does not satisfy the required key', t => { + const error = t.throws( + () => + assertHeadersOrder({ + Chrome: ['Host'], + firefox: ['Host'], + safari: ['Accept'] + }), + { instanceOf: TypeError } + ) + t.is(error.message, 'Unexpected headers order for: chrome') +}) + +const invalidOrders = [ + ['missing chrome', { firefox: ['Host'], safari: ['Accept'] }, 'chrome'], + ['missing firefox', { chrome: ['Host'], safari: ['Accept'] }, 'firefox'], + ['missing safari', { chrome: ['Host'], firefox: ['Host'] }, 'safari'], + [ + 'an empty list for a required browser', + { chrome: [], firefox: ['Host'], safari: ['Accept'] }, + 'chrome' + ], + [ + 'an empty list for an extra browser', + { ...validOrder, edge: [] }, + 'edge' + ], + [ + 'a number in a required list', + { ...validOrder, chrome: ['Host', 1] }, + 'chrome' + ], + [ + 'null in a required list', + { ...validOrder, firefox: ['Host', null] }, + 'firefox' + ], + [ + 'an object in a required list', + { ...validOrder, safari: ['Accept', {}] }, + 'safari' + ], + [ + 'a boolean in a required list', + { ...validOrder, edge: [true] }, + 'edge' + ], + [ + 'a nested list', + { ...validOrder, chrome: ['Host', ['Accept']] }, + 'chrome' + ], + [ + 'a string where a list is required', + { ...validOrder, chrome: 'Host' }, + 'chrome' + ], + [ + 'a number where a list is required', + { ...validOrder, edge: 1 }, + 'edge' + ], + ['null', null, 'chrome, firefox, safari'], + ['undefined', undefined, 'chrome, firefox, safari'], + ['an empty object', {}, 'chrome, firefox, safari'], + ['a number', 1, 'chrome, firefox, safari'], + ['a boolean', false, 'chrome, firefox, safari'], + ['an empty string', '', 'chrome, firefox, safari'], + ['a string', 'ab', 'chrome, firefox, safari, 0, 1'], + ['an array', ['Host'], 'chrome, firefox, safari, 0'] +] + +for (const [title, headersOrder, expected] of invalidOrders) { + test(`rejects ${title}`, t => { + const error = t.throws(() => assertHeadersOrder(headersOrder), { + instanceOf: TypeError + }) + t.is(error.message, `Unexpected headers order for: ${expected}`) + }) +} + +test('a failed rename leaves the destination in place and removes the temporary file', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = path.join(directory, 'headers-order.json') + await mkdir(destination) + await writeFile(path.join(destination, 'keep.txt'), 'keep') + + await t.throwsAsync(writeAtomically(destination, 'new')) + t.is(await readFile(path.join(destination, 'keep.txt'), 'utf8'), 'keep') + t.deepEqual(await temporaryNames(directory), []) +}) + +test('a failed write leaves no temporary file', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = path.join(directory, 'missing', 'headers-order.json') + + const error = await t.throwsAsync(writeAtomically(destination, 'new')) + t.is(error.code, 'ENOENT') + t.deepEqual(await temporaryNames(directory), []) +}) + +test('a cleanup failure does not hide the rename error', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const io = { + writeFile: async () => {}, + rename: async () => { + throw new Error('rename failed') + }, + rm: async () => { + throw new Error('cleanup failed') + } + } + + const error = await t.throwsAsync( + writeAtomically(path.join(directory, 'headers-order.json'), 'new', io) + ) + t.is(error.message, 'rename failed') +}) + +test('a cleanup failure does not hide the write error', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const io = { + writeFile: async () => { + throw new Error('write failed') + }, + rename: async () => {}, + rm: async () => { + throw new Error('cleanup failed') + } + } + + const error = await t.throwsAsync( + writeAtomically(path.join(directory, 'headers-order.json'), 'new', io) + ) + t.is(error.message, 'write failed') +}) + +test('a successful write replaces the destination and leaves no temporary file', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await writeAtomically(destination, 'new') + t.is(await readFile(destination, 'utf8'), 'new') + t.deepEqual(await temporaryNames(directory), []) +}) + +const serveJson = (status, body, headers = {}) => (req, res) => { + if (req.url === '/redirect') { + res.writeHead(302, { location: '/final' }) + res.end() + return + } + if (req.url === '/permanent') { + res.writeHead(301, { location: '/final' }) + res.end() + return + } + res.writeHead(status, headers) + res.end(body) +} + +test('main writes the serialized order and does not append a trailing newline', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + const body = JSON.stringify(validOrder) + + await withServer(serveJson(200, body), async url => { + await main({ destination, url: `${url}/final` }) + }) + + const written = await readFile(destination, 'utf8') + t.is(written, JSON.stringify(validOrder, null, 2)) + t.false(written.endsWith('\n')) + t.deepEqual(await temporaryNames(directory), []) +}) + +test('main persists the same bytes as the committed header order', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + const committed = await readFile(SHIPPED, 'utf8') + + await withServer(serveJson(200, JSON.stringify(shipped)), async url => { + await main({ destination, url }) + }) + + t.is(await readFile(destination, 'utf8'), committed) +}) + +test('main follows a temporary redirect to the payload', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await withServer(serveJson(200, JSON.stringify(validOrder)), async url => { + await main({ destination, url: `${url}/redirect` }) + }) + + t.is(await readFile(destination, 'utf8'), JSON.stringify(validOrder, null, 2)) +}) + +test('main follows a permanent redirect to the payload', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await withServer(serveJson(200, JSON.stringify(validOrder)), async url => { + await main({ destination, url: `${url}/permanent` }) + }) + + t.is(await readFile(destination, 'utf8'), JSON.stringify(validOrder, null, 2)) +}) + +test('main accepts a JSON body with a byte-order mark', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + const body = `\uFEFF${JSON.stringify(validOrder)}` + + await withServer(serveJson(200, body), async url => { + await main({ destination, url }) + }) + + t.is(await readFile(destination, 'utf8'), JSON.stringify(validOrder, null, 2)) +}) + +test('main accepts JSON without a content type', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await withServer(serveJson(200, JSON.stringify(validOrder), {}), async url => { + await main({ destination, url }) + }) + + t.is(await readFile(destination, 'utf8'), JSON.stringify(validOrder, null, 2)) +}) + +test('main accepts a gzip-encoded JSON body', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + const body = gzipSync(Buffer.from(JSON.stringify(validOrder))) + + await withServer( + serveJson(200, body, { + 'content-encoding': 'gzip', + 'content-type': 'application/json' + }), + async url => { + await main({ destination, url }) + } + ) + + t.is(await readFile(destination, 'utf8'), JSON.stringify(validOrder, null, 2)) +}) + +const rejectedResponses = [ + ['an HTTP 404', 404, 'missing', /HTTP 404 for /], + ['an HTTP 500', 500, 'err', /HTTP 500 for /], + ['an empty 204', 204, '', /Unexpected end of JSON input/], + ['an empty 200', 200, '', /Unexpected end of JSON input/], + ['an HTML 200', 200, '', /is not valid JSON/], + ['null', 200, 'null', /Unexpected headers order for: chrome, firefox, safari/], + [ + 'an invalid browser list', + 200, + JSON.stringify({ ...validOrder, edge: [] }), + /Unexpected headers order for: edge/ + ] +] + +for (const [title, status, body, message] of rejectedResponses) { + test(`main leaves the file untouched for ${title}`, async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await withServer(serveJson(status, body), async url => { + await t.throwsAsync(main({ destination, url }), { message }) + }) + + t.is(await readFile(destination, 'utf8'), ORIGINAL) + t.deepEqual(await temporaryNames(directory), []) + }) +} + +test('main leaves the file untouched when the host refuses the connection', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = await destinationIn(directory) + + await t.throwsAsync( + main({ destination, url: 'http://127.0.0.1:1/headers-order.json' }) + ) + t.is(await readFile(destination, 'utf8'), ORIGINAL) +}) + +const cliStub = source => `'use strict'\n${source}\n` + +const runCli = stubSource => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'headers-order-cli-')) + const stub = path.join(directory, 'stub.js') + fs.writeFileSync(stub, cliStub(stubSource)) + const before = fs.readFileSync(SHIPPED) + try { + const result = spawnSync(process.execPath, ['--require', stub, SCRIPT], { + encoding: 'utf8', + timeout: 10000 + }) + const after = fs.readFileSync(SHIPPED) + return { + result, + unchanged: before.equals(after), + temporary: `${SHIPPED}.${result.pid}.tmp` + } + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } +} + +test('running the updater exits 1 when the download fails and does not stage a write', t => { + const { result, unchanged, temporary } = runCli( + "global.fetch = async () => { throw new TypeError('fetch failed') }" + ) + t.is(result.status, 1) + t.true(result.stderr.includes('fetch failed')) + t.true(unchanged) + t.false(fs.existsSync(temporary)) +}) + +test('running the updater exits 1 on an HTTP error', t => { + const { result, unchanged } = runCli( + 'global.fetch = async () => ({ ok: false, status: 404 })' + ) + t.is(result.status, 1) + t.true(result.stderr.includes(`HTTP 404 for ${SOURCE_URL}`)) + t.true(unchanged) +}) + +test('running the updater writes the default file and exits 0 for a valid payload', t => { + const payload = JSON.stringify(shipped) + const { result, unchanged, temporary } = runCli( + `global.fetch = async () => ({ ok: true, status: 200, json: async () => (${payload}) })` + ) + t.is(result.status, 0) + t.is(result.stderr, '') + t.true(unchanged) + t.false(fs.existsSync(temporary)) +}) + +test('running the updater exits 1 when the payload is not the expected shape', t => { + const { result, unchanged } = runCli( + `global.fetch = async () => ({ ok: true, status: 200, json: async () => (${JSON.stringify({ chrome: [], firefox: ['Host'], safari: ['Accept'] })}) })` + ) + t.is(result.status, 1) + t.regex(result.stderr, /Unexpected headers order for: chrome/) + t.true(unchanged) +}) + +test('requiring the updater does not fetch', t => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'headers-order-require-') + ) + const stub = path.join(directory, 'stub.js') + fs.writeFileSync( + stub, + cliStub( + "global.fetch = async () => { throw new TypeError('fetch failed') }" + ) + ) + try { + const result = spawnSync( + process.execPath, + ['--require', stub, '-e', `require(${JSON.stringify(SCRIPT)})`], + { encoding: 'utf8', timeout: 5000 } + ) + t.is(result.status, 0) + t.false(result.stderr.includes('fetch failed')) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } +}) From d342d8a8094aa46f994a21ad014c0402107678ad Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 15:09:31 +0000 Subject: [PATCH 4/5] test(scripts): drop the extra async wrapper around the temp directory Co-authored-by: Cursor --- test/update-headers-order.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/update-headers-order.js b/test/update-headers-order.js index 0987dde..2df798f 100644 --- a/test/update-headers-order.js +++ b/test/update-headers-order.js @@ -31,10 +31,8 @@ const validOrder = { edge: ['Host'] } -const temporaryDirectory = async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), 'headers-order-')) - return directory -} +const temporaryDirectory = () => + mkdtemp(path.join(os.tmpdir(), 'headers-order-')) const listen = handler => new Promise((resolve, reject) => { From 45d94690392e710746948fb46787a6eb89f94d2c Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 28 Oct 2026 17:25:11 +0000 Subject: [PATCH 5/5] fix(install): refresh header order on install and keep the bundled file A failed download warns and leaves the shipped data in place. The weekly cron still fails closed with --strict. Co-authored-by: Cursor --- .github/workflows/cron.yml | 2 +- package.json | 2 + scripts/postinstall.js | 23 +++++++++++ test/package.js | 10 +++-- test/postinstall.js | 85 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 scripts/postinstall.js create mode 100644 test/postinstall.js diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 73114dc..956a6a2 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -29,7 +29,7 @@ jobs: run: | git config --global user.email ${{ secrets.GIT_EMAIL }} git config --global user.name ${{ secrets.GIT_USERNAME }} - node scripts/update-headers-order.js + node scripts/postinstall.js --strict npm test git add src/headers-order.json git diff-index --quiet HEAD || git commit -m 'build(update): headers order' --no-verify diff --git a/package.json b/package.json index 2ff0777..e64f521 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "files": [ "hook.js", "index.js", + "scripts", "src" ], "scripts": { @@ -59,6 +60,7 @@ "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true", "coverage": "nyc report --reporter=text-lcov | coveralls", "lint": "standard-markdown README.md && standard", + "postinstall": "node scripts/postinstall.js", "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)", "prerelease": "npm run contributors && npm run update:headers-order && npm test", "pretest": "npm run lint", diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..faeb18d --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,23 @@ +'use strict' + +const { main } = require('./update-headers-order') + +const install = async ({ strict = false, ...options } = {}) => { + try { + await main(options) + } catch (error) { + if (strict) throw error + console.warn( + `https-tls: couldn't update headers order, using the bundled one (${error.message})` + ) + } +} + +if (require.main === module) { + install({ strict: process.argv.includes('--strict') }).catch(error => { + console.error(error) + process.exitCode = 1 + }) +} + +module.exports = { install } diff --git a/test/package.js b/test/package.js index 980c13e..8ee3c29 100644 --- a/test/package.js +++ b/test/package.js @@ -18,17 +18,19 @@ const packedFiles = () => { return files.map(file => file.path) } -test('installing runs no scripts', t => { +test('install refreshes header order and falls back to the bundled file', t => { const installScripts = INSTALL_LIFECYCLE_SCRIPTS.filter( name => name in pkg.scripts ) - t.deepEqual(installScripts, []) + t.deepEqual(installScripts, ['postinstall']) + t.is(pkg.scripts.postinstall, 'node scripts/postinstall.js') }) -test('the published package ships the headers order data and no release scripts', t => { +test('the published package ships the headers order and the install script', t => { const files = packedFiles() t.true(files.includes('src/headers-order.json')) - t.false(files.some(file => file.startsWith('scripts/'))) + t.true(files.includes('scripts/postinstall.js')) + t.true(files.includes('scripts/update-headers-order.js')) }) test('release refreshes header order before testing and stages it only after a successful update', t => { diff --git a/test/postinstall.js b/test/postinstall.js new file mode 100644 index 0000000..3688fa9 --- /dev/null +++ b/test/postinstall.js @@ -0,0 +1,85 @@ +'use strict' + +const { spawnSync } = require('child_process') +const fs = require('fs') +const { mkdtemp, readFile, rm, writeFile } = require('fs/promises') +const os = require('os') +const path = require('path') + +const test = require('ava').default + +const { install } = require('../scripts/postinstall') + +const SCRIPT = path.join(__dirname, '../scripts/postinstall.js') +const SHIPPED = path.join(__dirname, '../src/headers-order.json') +const ORIGINAL = 'original\n' +const REFUSED = 'http://127.0.0.1:1/headers-order.json' + +const temporaryDirectory = () => + mkdtemp(path.join(os.tmpdir(), 'headers-order-install-')) + +const runCli = (args, stubSource) => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'headers-order-install-cli-') + ) + const stub = path.join(directory, 'stub.js') + fs.writeFileSync(stub, `'use strict'\n${stubSource}\n`) + const before = fs.readFileSync(SHIPPED) + try { + const result = spawnSync(process.execPath, ['--require', stub, SCRIPT, ...args], { + encoding: 'utf8', + timeout: 10000 + }) + return { + result, + unchanged: before.equals(fs.readFileSync(SHIPPED)) + } + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } +} + +test('a failed refresh keeps the bundled file', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = path.join(directory, 'headers-order.json') + await writeFile(destination, ORIGINAL) + + await install({ destination, url: REFUSED }) + + t.is(await readFile(destination, 'utf8'), ORIGINAL) +}) + +test('a strict refresh failure is an error', async t => { + const directory = await temporaryDirectory() + t.teardown(() => rm(directory, { recursive: true, force: true })) + const destination = path.join(directory, 'headers-order.json') + await writeFile(destination, ORIGINAL) + + await t.throwsAsync(install({ destination, strict: true, url: REFUSED })) + t.is(await readFile(destination, 'utf8'), ORIGINAL) +}) + +test('install exits 0 when the download fails', t => { + const { result, unchanged } = runCli( + [], + "global.fetch = async () => { throw new TypeError('fetch failed') }" + ) + + t.is(result.status, 0) + t.true(result.stderr.includes('using the bundled one')) + t.true(result.stderr.includes('fetch failed')) + t.true(unchanged) +}) + +test('install exits 1 when the download fails and strict is set', t => { + const { result, unchanged } = runCli( + ['--strict'], + "global.fetch = async () => { throw new TypeError('fetch failed') }" + ) + + t.is(result.status, 1) + t.true(result.stderr.includes('fetch failed')) + t.false(result.stderr.includes('using the bundled one')) + t.true(unchanged) +})