Skip to content
127 changes: 120 additions & 7 deletions components/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
writeFile,
} from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { createReadStream, existsSync, readdirSync } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
Expand Down Expand Up @@ -203,7 +204,10 @@ export async function extractApplication(application: Application) {
application.name,
'npm',
['pack', '--json', application.packageIdentifier],
parentDirPath
parentDirPath,
undefined,
undefined,
application.npmUserconfigPath
);
if (code !== 0) {
if (isSSHAuthFailure(stderr)) {
Expand Down Expand Up @@ -317,7 +321,8 @@ export async function installApplication(application: Application) {
args,
application.dirPath,
application.install?.timeout,
customOnLine
customOnLine,
application.npmUserconfigPath
);
// if it succeeds, return
if (code === 0) {
Expand Down Expand Up @@ -377,7 +382,8 @@ export async function installApplication(application: Application) {
application.install?.allowInstallScripts ? ['install'] : ['install', '--ignore-scripts'], // All of `npm`, `yarn`, and `pnpm` support the `install` command. If we need to configure options here we may have to use some other defaults though
application.dirPath,
application.install?.timeout,
pmOnLine
pmOnLine,
application.npmUserconfigPath
);

// if it succeeds, return
Expand Down Expand Up @@ -433,7 +439,8 @@ export async function installApplication(application: Application) {
npmInstallArgs,
application.dirPath,
application.install?.timeout,
npmOnLine
npmOnLine,
application.npmUserconfigPath
);

// if it succeeds, return
Expand Down Expand Up @@ -469,6 +476,7 @@ interface ApplicationOptions {
packageIdentifier?: string;
install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
onInstallLine?: OnInstallLine;
registryAuth?: RegistryAuthEntry[];
}

export class Application {
Expand All @@ -480,19 +488,72 @@ export class Application {
dirPath: string;
logger: Logger;
packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".

constructor({ name, payload, packageIdentifier, install, onInstallLine }: ApplicationOptions) {
// Transient registry auth provided by a deploy. The token is held only in memory and a
// per-deploy `.npmrc`; it is never persisted to config, hdb_deployment, or replicated.
registryAuth?: RegistryAuthEntry[];
// Path to the per-deploy `.npmrc`, set by writeTransientNpmrc() during prepareApplication and
// passed to the spawn calls; undefined when no registry auth was provided.
npmUserconfigPath?: string;
#npmrcTempDir?: string;

constructor({ name, payload, packageIdentifier, install, onInstallLine, registryAuth }: ApplicationOptions) {
this.name = name;
this.payload = payload;
this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
this.install = install;
this.onInstallLine = onInstallLine;
this.registryAuth = registryAuth;
const componentsRoot = getConfigPath(CONFIG_PARAMS.COMPONENTSROOT);
if (!componentsRoot) throw new Error('componentsRoot is not configured');
this.dirPath = join(componentsRoot, name);
this.logger = logger.loggerWithTag(name);
this.packageManagerPrefix = getConfigValue(CONFIG_PARAMS.APPLICATIONS_PACKAGEMANAGERPREFIX);
}

// Write the transient `.npmrc` into a fresh 0700 temp dir (file mode 0600) and record its path
// so the deploy's npm spawns authenticate against the private registry. No-op without registry auth.
//
// Because `nonInteractiveSpawn` points npm at this single file (replacing any inherited
// npm_config_userconfig), prepend the contents of an already-configured userconfig — e.g. a
// fabric-injected file carrying cluster registries, a proxy, or a cafile — so those settings
// survive. The transient auth is appended last so it wins on conflict (npm honors the last
// value for a given key).
async writeTransientNpmrc(): Promise<void> {
if (!this.registryAuth?.length) return;
// Defensive: if called more than once, remove the prior temp dir first so it isn't leaked.
if (this.#npmrcTempDir) await this.cleanupTransientNpmrc();
this.#npmrcTempDir = await mkdtemp(join(tmpdir(), 'harper-npmrc-'));
const npmrcPath = join(this.#npmrcTempDir, '.npmrc');
Comment thread
kriszyp marked this conversation as resolved.
let content = '';
const inheritedUserconfig = process.env.npm_config_userconfig ?? process.env.NPM_CONFIG_USERCONFIG;
if (inheritedUserconfig) {
try {
const inherited = await readFile(inheritedUserconfig, 'utf8');
content = inherited.endsWith('\n') ? inherited : inherited + '\n';
} catch (error: any) {
// Missing inherited file is fine (npm would have created/ignored it); surface anything else.
if (error?.code !== 'ENOENT') throw error;
}
}
content += buildNpmrcContent(this.registryAuth);
await writeFile(npmrcPath, content, { mode: 0o600 });
this.npmUserconfigPath = npmrcPath;
}

// Remove the transient `.npmrc` (and its temp dir) once the deploy's npm work is done.
async cleanupTransientNpmrc(): Promise<void> {
if (!this.#npmrcTempDir) return;
try {
await rm(this.#npmrcTempDir, { recursive: true, force: true });
} catch (error) {
// Called from prepareApplication's finally; a throw here (e.g. a Windows file lock) would
// mask the original deploy error and skip broadcastDeployEnd. Log and always clear state.
this.logger.warn(`Failed to remove transient .npmrc dir ${this.#npmrcTempDir}:`, error);
} finally {
this.#npmrcTempDir = undefined;
this.npmUserconfigPath = undefined;
Comment thread
kriszyp marked this conversation as resolved.
}
}
Comment thread
kriszyp marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -532,9 +593,13 @@ export function derivePackageIdentifier(packageIdentifier: string) {
export async function prepareApplication(application: Application) {
await broadcastDeployStart(application.name);
try {
// Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
// `npm install` authenticate against the private registry; always remove it afterward.
await application.writeTransientNpmrc();
await extractApplication(application);
await installApplication(application);
} finally {
await application.cleanupTransientNpmrc();
broadcastDeployEnd(application.name);
}
}
Expand Down Expand Up @@ -638,6 +703,41 @@ function getGitSSHCommand() {
}
}

export interface RegistryAuthEntry {
registry: string;
token: string;
scope?: string;
}

// Normalize a registry to a full URL with a scheme and trailing slash, e.g.
// `npm.pkg.github.com` or `//npm.pkg.github.com` → `https://npm.pkg.github.com/`.
function normalizeRegistryUrl(registry: string): string {
let url = registry.trim();
if (!/^https?:\/\//i.test(url)) {
url = url.startsWith('//') ? `https:${url}` : `https://${url}`;
}
if (!url.endsWith('/')) url += '/';
return url;
}

// Build the contents of a transient `.npmrc` from registry auth entries: an auth-token line keyed
// by npm's registry auth key (scheme stripped, leading `//`, trailing `/`) plus a registry-routing
// line. A scope routes only that `@scope` to the registry (`@scope:registry=…`); without a scope
// the entry sets npm's default `registry=…` so an unscoped package spec (e.g. `npm:my-private-app`)
// or its transitive deps actually resolve against this registry rather than the public default.
// A scope-less entry therefore requires its registry to serve/proxy whatever npm needs to install;
// with multiple scope-less entries npm's last-value-wins applies to the default `registry`.
export function buildNpmrcContent(registryAuth: RegistryAuthEntry[]): string {
const lines: string[] = [];
for (const { registry, token, scope } of registryAuth) {
const registryUrl = normalizeRegistryUrl(registry);
const authKey = registryUrl.replace(/^https?:/i, '');
lines.push(`${authKey}:_authToken=${token}`);
lines.push(scope ? `${scope}:registry=${registryUrl}` : `registry=${registryUrl}`);
}
return lines.join('\n') + '\n';
}

/**
* Execute a command (using `spawn`) with stdin ignored.
*
Expand Down Expand Up @@ -697,7 +797,8 @@ export function nonInteractiveSpawn(
args: string[],
cwd: string,
timeoutMs: number = 60 * 60 * 1000,
onLine?: (stream: 'stdout' | 'stderr', line: string) => void
onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
npmUserconfigPath?: string
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
logger
Expand All @@ -711,6 +812,18 @@ export function nonInteractiveSpawn(
env.GIT_SSH_COMMAND = gitSSHCommand;
}

// A deploy carrying transient registry auth points npm at a per-deploy `.npmrc` so
// `npm pack`/`install` can authenticate against a private registry without the token
// ever touching disk durably, the package reference, config, or hdb_deployment.
if (npmUserconfigPath) {
// On case-insensitive platforms (Windows) an inherited NPM_CONFIG_USERCONFIG would
// shadow the lowercase key we set, so drop any existing case variant first.
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'npm_config_userconfig') delete env[key];
}
env.npm_config_userconfig = npmUserconfigPath;
}

if (process.platform === 'win32' && command === 'npm') {
command = 'npm.cmd';
}
Expand Down
12 changes: 12 additions & 0 deletions components/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,16 @@ async function deployComponent(req) {
// — their install output goes to the local logger only; cross-node install
// streaming would need extra plumbing and isn't wired here.
onInstallLine: emitter ? (manager, stream, line) => emit('install', { manager, stream, line }) : undefined,
// Transient private-registry auth, used here for this node's npm pack/install. The
// Application ctor captures it into application.registryAuth; we strip it from req
// immediately (below) so the token is never persisted or sent to peers — peers
// authenticate via their own fabric-injected NPM_CONFIG_USERCONFIG.
registryAuth: req.registryAuth,
});
// Strip the token from req immediately after the ctor captures it, so it can't survive into an
// error/log path if prepareApplication or loadComponent throws below (the previous strip point
// after loadComponent only ran on the success path, leaking the token on failure).
delete req.registryAuth;

emit('phase', { phase: 'prepare', status: 'start' });
await prepareApplication(application);
Expand Down Expand Up @@ -488,6 +497,9 @@ async function deployComponent(req) {
// ProgressEmitter holds function listeners that can't survive the replication
// channel's serialization; strip it unconditionally.
delete req.progress;
// req.registryAuth was already deleted immediately after the Application ctor (above) so the
// token never reaches the replication channel or a peer's operation log; peers authenticate
// against the private registry via their own fabric-injected NPM_CONFIG_USERCONFIG on reinstall.
if (systemReplicated && recorder) {
// The hdb_deployment row + payload_blob will reach peers via table replication,
// so peers can look up the payload by deployment_id. Drop req.payload to keep
Expand Down
13 changes: 13 additions & 0 deletions components/operationsValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@ function deployComponentValidator(req) {
install_timeout: Joi.number().optional(),
install_allow_scripts: Joi.boolean().optional(),
force: Joi.boolean().optional(),
// Transient private-registry auth: never persisted, never replicated. Used only for this
// node's npm pack/install during the deploy.
registryAuth: Joi.array()
.items(
Joi.object({
registry: Joi.string().required(),
token: Joi.string().required(),
scope: Joi.string()
.pattern(/^@[a-z0-9-_.]+$/)
.optional(),
Comment thread
kriszyp marked this conversation as resolved.
})
)
.optional(),
});

return validator.validateBySchema(req, deployProjSchema);
Expand Down
5 changes: 3 additions & 2 deletions server/serverHelpers/serverUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ export async function processLocalTransaction(req: OperationRequest, operationFu
harperLogger.logLevel === terms.LOG_LEVELS.TRACE)
) {
// Need to remove auth variables, but we don't want to create an object unless
// the logging is actually going to happen.
// the logging is actually going to happen. registryAuth carries a transient private
// registry token on deploy_component and must never reach the operations log.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { hdb_user, hdbAuthHeader, password, payload, ...cleanBody } = req.body;
const { hdb_user, hdbAuthHeader, password, payload, registryAuth, ...cleanBody } = req.body;
operationLog.info(cleanBody);
}
} catch (e) {
Expand Down
Loading
Loading