Skip to content

Commit dc66a44

Browse files
anombyte93claude
andauthored
feat(release): npm-auth preflight fails closed before publish (#3) (#17)
* feat(release): npm-auth preflight fails closed before publish (#3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): robust main() entry guard (pathToFileURL) + stricter npmrc match Addresses code-review I1/M1/M2 on the npm-auth preflight (#3): - I1: a naive file://${argv[1]} guard silently skipped main() (and the whole auth check) when the install path had spaces or was symlinked — a publish could proceed with NO auth check. Use pathToFileURL(argv[1]).href. - M1: repurpose the previously-unused node:url import. - M2: anchor the npmrc token match with startsWith so a crafted host segment can't false-pass. Adds an I1 regression test (script run from a path with spaces still exits 1). Verified: node --test 10/10; no-token + spaces-path both exit 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b9ea8b8 commit dc66a44

4 files changed

Lines changed: 355 additions & 1 deletion

File tree

RELEASE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Release process
2+
3+
This package ships to npm as `prd-taskmaster`. Releases are gated so that an
4+
**auth failure is caught in one second, before any expensive tag/build/publish work**
5+
not after the build is half-done and `npm publish` hangs on an interactive web-auth wall.
6+
7+
## Release gate order
8+
9+
Run the gates in this exact order. Each one fails closed (non-zero exit) and stops the release:
10+
11+
1. **`release:preflight` (npm auth)**`npm run release:preflight`
12+
- Confirms a usable npm token exists **before** anything else.
13+
- Runs `scripts/check-npm-auth.mjs`. If no token is found it exits `1` immediately,
14+
*before any network call*, so it can never stall on a browser login.
15+
2. **version-sync**`npm run version:check`
16+
- `scripts/check-version-sync.js` — every version source-of-truth must agree
17+
(`package.json`, `.claude-plugin/plugin.json`, `prd_taskmaster/__init__.py`).
18+
3. **tag**`git tag vX.Y.Z && git push --tags`
19+
4. **publish**`npm publish`
20+
21+
### Automatic enforcement via `prepublishOnly`
22+
23+
`npm publish` runs the package's `prepublishOnly` script first. It now runs **auth, then
24+
version-sync**:
25+
26+
```json
27+
"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js"
28+
```
29+
30+
So even if you forget to run `release:preflight` by hand, `npm publish` will refuse to
31+
proceed when npm auth is missing/expired — auth is checked **before** the publish does any
32+
real work. This is the fix for the recurring "publish stalls on a browser web-auth timeout"
33+
failure: you now fail fast at the gate instead of mid-publish.
34+
35+
## Auth: token path vs web-auth path
36+
37+
`scripts/check-npm-auth.mjs` looks for a token in two places (pure, offline detection):
38+
39+
### (a) Token path — preferred (non-interactive, no browser)
40+
41+
Either of:
42+
43+
- Set an environment variable:
44+
```bash
45+
export NPM_TOKEN=<token>
46+
```
47+
(The operator keeps an npm token in **Bitwarden** — retrieve it from there.)
48+
- Or add a line to `~/.npmrc`:
49+
```
50+
//registry.npmjs.org/:_authToken=<token>
51+
```
52+
53+
Then re-run `npm run release:preflight` to confirm.
54+
55+
If a token is present, the preflight also runs `npm whoami` as a **soft** confirmation —
56+
a network failure there is only a warning (the token may still be valid), and it never
57+
blocks. The hard fail-closed behaviour only triggers when **no token at all** is found.
58+
59+
### (b) Web-auth path — only if no token is available
60+
61+
Run `npm login` in the operator's **real desktop browser** (already logged in, on their
62+
own residential IP). Do **not** drive the login URL in an automated/headless browser — that
63+
advertises automation and trips Cloudflare/Turnstile "Just a moment" challenges. Approve the
64+
sign-in in one click, then re-run `npm run release:preflight`.
65+
66+
## One-shot release checklist
67+
68+
```bash
69+
npm run release:preflight # 1. auth — fails closed if no token
70+
npm run version:check # 2. versions agree
71+
pytest tests/ # (sanity — full suite stays green)
72+
git tag vX.Y.Z && git push --tags # 3. tag
73+
npm publish # 4. publish (re-runs auth + version-sync via prepublishOnly)
74+
```

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
}
3232
},
3333
"scripts": {
34-
"prepublishOnly": "node scripts/check-version-sync.js",
34+
"prepublishOnly": "node scripts/check-npm-auth.mjs && node scripts/check-version-sync.js",
35+
"release:preflight": "node scripts/check-npm-auth.mjs",
3536
"version:check": "node scripts/check-version-sync.js",
3637
"postinstall": "pip install -r mcp-server/requirements.txt 2>/dev/null || echo 'WARN: Python MCP deps not installed. MCP tools will not start. Run: pip install -r node_modules/prd-taskmaster/mcp-server/requirements.txt'",
3738
"test": "pytest tests/",

scripts/check-npm-auth.mjs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Release-auth preflight — FAILS CLOSED before any expensive tag/build/publish work.
4+
*
5+
* The recurring failure this guards: `npm publish` stalls on a browser web-auth
6+
* timeout because nobody checked that npm auth was actually valid FIRST. Wired ahead
7+
* of version-sync in `prepublishOnly`, this refuses to start a publish when no usable
8+
* npm token is present — so the operator finds out in one second, not after the tag/
9+
* build is half-done and the publish hangs on an interactive login.
10+
*
11+
* Design: the detection logic (`resolveNpmAuth`) is a PURE function with no network
12+
* and no filesystem access, so it is unit-testable offline. The CLI wrapper reads the
13+
* real environment + `~/.npmrc`, and only AFTER confirming a token exists does it
14+
* optionally probe the network via `npm whoami` (soft — a network failure is a warning,
15+
* never a crash, and never blocks the no-token fail-closed exit).
16+
*/
17+
18+
import fs from "node:fs";
19+
import os from "node:os";
20+
import path from "node:path";
21+
import { execFileSync } from "node:child_process";
22+
import { pathToFileURL } from "node:url";
23+
24+
const REGISTRY_AUTH_LINE = "//registry.npmjs.org/:_authToken=";
25+
26+
/**
27+
* Pure auth-detection. No network, no filesystem — everything is passed in.
28+
*
29+
* @param {{ env?: Record<string,string|undefined>, npmrc?: string }} input
30+
* env — a process.env-like object; NPM_TOKEN (or NODE_AUTH_TOKEN) means a token.
31+
* npmrc — the raw text of an .npmrc file (may be empty/undefined).
32+
* @returns {{ ok: boolean, method: ("NPM_TOKEN"|"npmrc-token"|null), why: string }}
33+
*/
34+
export function resolveNpmAuth({ env = {}, npmrc = "" } = {}) {
35+
// 1) Environment token wins — this is how CI and the Bitwarden-token path inject auth.
36+
const envToken = env.NPM_TOKEN || env.NODE_AUTH_TOKEN;
37+
if (typeof envToken === "string" && envToken.trim() !== "") {
38+
return {
39+
ok: true,
40+
method: "NPM_TOKEN",
41+
why: "NPM_TOKEN (or NODE_AUTH_TOKEN) is set in the environment.",
42+
};
43+
}
44+
45+
// 2) An //registry.npmjs.org/:_authToken=... line in the npmrc string.
46+
if (typeof npmrc === "string" && npmrc.length > 0) {
47+
const hasAuthToken = npmrc
48+
.split(/\r?\n/)
49+
.map((line) => line.trim())
50+
.some(
51+
(line) =>
52+
!line.startsWith("#") &&
53+
!line.startsWith(";") &&
54+
// anchor on the line start (already trimmed) so a crafted host segment
55+
// like //evil.com//registry.npmjs.org/:_authToken= can't false-pass.
56+
line.startsWith(REGISTRY_AUTH_LINE) &&
57+
// require a non-empty value after the '='
58+
line.slice(REGISTRY_AUTH_LINE.length).trim() !== ""
59+
);
60+
if (hasAuthToken) {
61+
return {
62+
ok: true,
63+
method: "npmrc-token",
64+
why: `Found '${REGISTRY_AUTH_LINE}...' in the provided .npmrc.`,
65+
};
66+
}
67+
}
68+
69+
// 3) Nothing usable.
70+
return {
71+
ok: false,
72+
method: null,
73+
why: "No NPM_TOKEN in the environment and no //registry.npmjs.org/:_authToken line in ~/.npmrc.",
74+
};
75+
}
76+
77+
/** Read a file as text, returning "" if it does not exist / is unreadable. */
78+
function readNpmrc(npmrcPath) {
79+
try {
80+
return fs.readFileSync(npmrcPath, "utf8");
81+
} catch {
82+
return "";
83+
}
84+
}
85+
86+
/** The FAIL banner — documents both remediation paths. Returns the string for testability. */
87+
function failMessage() {
88+
return [
89+
"✖ FAIL: npm release-auth preflight — no usable npm auth found.",
90+
" Refusing to start publish work (tag/build/version-sync) with no valid token.",
91+
"",
92+
" Two ways to fix this BEFORE retrying the release:",
93+
"",
94+
" (a) Token path (preferred — non-interactive, no browser):",
95+
" • export NPM_TOKEN=<token> (the operator keeps an npm token in Bitwarden),",
96+
" OR",
97+
" • add this line to ~/.npmrc:",
98+
` ${REGISTRY_AUTH_LINE}<token>`,
99+
" then re-run: npm run release:preflight",
100+
"",
101+
" (b) Web-auth path (only if no token is available):",
102+
" • run `npm login` in the operator's REAL browser (their own desktop browser,",
103+
" already logged in on their residential IP — not an automated/headless one,",
104+
" which trips Cloudflare/Turnstile). Complete the one-click approval, then",
105+
" re-run: npm run release:preflight",
106+
].join("\n");
107+
}
108+
109+
function main() {
110+
const env = process.env;
111+
const npmrcPath = path.join(os.homedir(), ".npmrc");
112+
const npmrc = readNpmrc(npmrcPath);
113+
114+
const result = resolveNpmAuth({ env, npmrc });
115+
116+
if (!result.ok) {
117+
// FAIL CLOSED — exit BEFORE any network call (no `npm whoami`), so this can never hang.
118+
console.error(failMessage());
119+
console.error(`\n detail: ${result.why}`);
120+
process.exit(1);
121+
}
122+
123+
// A token exists. Report HOW we found it.
124+
const where =
125+
result.method === "NPM_TOKEN"
126+
? "environment (NPM_TOKEN / NODE_AUTH_TOKEN)"
127+
: `npmrc (${npmrcPath})`;
128+
console.log(`✓ npm token present via ${result.method}${where}.`);
129+
130+
// OPTIONAL network confirmation. Soft: a failure here is a warning, never a crash,
131+
// because the token may be valid even when whoami can't reach the registry (offline,
132+
// proxy, registry blip). The fail-closed guarantee above does not depend on this.
133+
try {
134+
const who = execFileSync("npm", ["whoami"], {
135+
encoding: "utf8",
136+
stdio: ["ignore", "pipe", "pipe"],
137+
timeout: 15000,
138+
}).trim();
139+
console.log(`✓ PASS: authenticated to npm registry as '${who}'.`);
140+
} catch (err) {
141+
const reason = (err && (err.stderr || err.message) ? String(err.stderr || err.message) : "")
142+
.trim()
143+
.split(/\r?\n/)[0];
144+
console.warn(
145+
"⚠ WARN: token present but `npm whoami` could not confirm it" +
146+
(reason ? ` (${reason})` : "") +
147+
".\n Treating as a soft warning — proceeding. If the publish 401s, the token may be expired/invalid."
148+
);
149+
console.log("✓ PASS (soft): token present; whoami unverified.");
150+
}
151+
}
152+
153+
// Only run main() when executed directly, so tests can import resolveNpmAuth without
154+
// triggering the CLI side effects (reading ~/.npmrc, process.exit, network).
155+
// Use pathToFileURL so paths with spaces or symlinked invocations still match —
156+
// a naive `file://${argv[1]}` compare silently skips main() (and thus the whole
157+
// auth check) when the install path contains a space or is a symlink.
158+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
159+
main();
160+
}

scripts/check-npm-auth.test.mjs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Run with: node --test scripts/check-npm-auth.test.mjs
2+
//
3+
// Covers the pure resolveNpmAuth detection (offline, no network) and the CLI
4+
// fail-closed guarantee: the no-token path must exit non-zero WITHOUT touching
5+
// the network (no `npm whoami`), so it can never hang on a publish web-auth wall.
6+
7+
import test from "node:test";
8+
import assert from "node:assert/strict";
9+
import { spawnSync } from "node:child_process";
10+
import fs from "node:fs";
11+
import os from "node:os";
12+
import path from "node:path";
13+
import { fileURLToPath } from "node:url";
14+
15+
import { resolveNpmAuth } from "./check-npm-auth.mjs";
16+
17+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
18+
const SCRIPT = path.join(__dirname, "check-npm-auth.mjs");
19+
20+
test("resolveNpmAuth: NPM_TOKEN present → ok, method NPM_TOKEN", () => {
21+
const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_abc123" }, npmrc: "" });
22+
assert.equal(r.ok, true);
23+
assert.equal(r.method, "NPM_TOKEN");
24+
});
25+
26+
test("resolveNpmAuth: NODE_AUTH_TOKEN present → ok, method NPM_TOKEN", () => {
27+
const r = resolveNpmAuth({ env: { NODE_AUTH_TOKEN: "npm_ci_token" }, npmrc: "" });
28+
assert.equal(r.ok, true);
29+
assert.equal(r.method, "NPM_TOKEN");
30+
});
31+
32+
test("resolveNpmAuth: npmrc with _authToken → ok, method npmrc-token", () => {
33+
const npmrc = "//registry.npmjs.org/:_authToken=npm_fromfile\n";
34+
const r = resolveNpmAuth({ env: {}, npmrc });
35+
assert.equal(r.ok, true);
36+
assert.equal(r.method, "npmrc-token");
37+
});
38+
39+
test("resolveNpmAuth: neither → not ok, method null", () => {
40+
const r = resolveNpmAuth({ env: {}, npmrc: "" });
41+
assert.equal(r.ok, false);
42+
assert.equal(r.method, null);
43+
});
44+
45+
test("resolveNpmAuth: empty NPM_TOKEN string is not a token", () => {
46+
const r = resolveNpmAuth({ env: { NPM_TOKEN: " " }, npmrc: "" });
47+
assert.equal(r.ok, false);
48+
assert.equal(r.method, null);
49+
});
50+
51+
test("resolveNpmAuth: commented-out _authToken line does not count", () => {
52+
const npmrc = "# //registry.npmjs.org/:_authToken=npm_disabled\n";
53+
const r = resolveNpmAuth({ env: {}, npmrc });
54+
assert.equal(r.ok, false);
55+
});
56+
57+
test("resolveNpmAuth: _authToken line with empty value does not count", () => {
58+
const npmrc = "//registry.npmjs.org/:_authToken=\n";
59+
const r = resolveNpmAuth({ env: {}, npmrc });
60+
assert.equal(r.ok, false);
61+
});
62+
63+
test("resolveNpmAuth: env token wins even when npmrc has none", () => {
64+
const r = resolveNpmAuth({ env: { NPM_TOKEN: "npm_env" }, npmrc: "registry=https://x\n" });
65+
assert.equal(r.ok, true);
66+
assert.equal(r.method, "NPM_TOKEN");
67+
});
68+
69+
test("CLI: no-token path exits non-zero with a FAIL message and does not hang", () => {
70+
// Temp HOME with an EMPTY .npmrc, env scrubbed of any npm token.
71+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-test-"));
72+
fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8");
73+
74+
const env = { ...process.env };
75+
delete env.NPM_TOKEN;
76+
delete env.NODE_AUTH_TOKEN;
77+
env.HOME = tmpHome;
78+
env.USERPROFILE = tmpHome; // Windows-safe, harmless on POSIX
79+
80+
const res = spawnSync(process.execPath, [SCRIPT], {
81+
env,
82+
encoding: "utf8",
83+
timeout: 20000, // if it ever hangs on network, the test fails loudly instead
84+
});
85+
86+
fs.rmSync(tmpHome, { recursive: true, force: true });
87+
88+
assert.equal(res.error, undefined, `spawn must not error/timeout: ${res.error}`);
89+
assert.equal(res.status, 1, "no-token CLI must exit 1 (fail closed)");
90+
const out = `${res.stdout}\n${res.stderr}`;
91+
assert.match(out, /FAIL/, "must print a FAIL message");
92+
// Prove it exited BEFORE any whoami network probe.
93+
assert.doesNotMatch(out, /authenticated to npm registry/, "must not have reached whoami");
94+
});
95+
96+
test("CLI: still runs main() (fail-closed) when the script path contains spaces (I1 regression)", () => {
97+
// A naive `file://${argv[1]}` entry guard silently SKIPS main() when the path
98+
// has spaces (percent-encoding mismatch) — which would let a publish proceed
99+
// with NO auth check. pathToFileURL() fixes it. Prove main() still fires here.
100+
const spacesDir = fs.mkdtempSync(path.join(os.tmpdir(), "npm auth spaces-"));
101+
const copied = path.join(spacesDir, "check-npm-auth.mjs");
102+
fs.copyFileSync(SCRIPT, copied);
103+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "npmauth-home-"));
104+
fs.writeFileSync(path.join(tmpHome, ".npmrc"), "", "utf8");
105+
106+
const env = { ...process.env };
107+
delete env.NPM_TOKEN;
108+
delete env.NODE_AUTH_TOKEN;
109+
env.HOME = tmpHome;
110+
env.USERPROFILE = tmpHome;
111+
112+
const res = spawnSync(process.execPath, [copied], { env, encoding: "utf8", timeout: 20000 });
113+
114+
fs.rmSync(spacesDir, { recursive: true, force: true });
115+
fs.rmSync(tmpHome, { recursive: true, force: true });
116+
117+
assert.equal(res.status, 1, "main() must run (exit 1) even from a path containing spaces");
118+
assert.match(`${res.stdout}\n${res.stderr}`, /FAIL/);
119+
});

0 commit comments

Comments
 (0)