Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
719c612
feat: transient private-registry auth for deploy_component
kriszyp Jun 8, 2026
ea0d9e9
fix: route default registry for scope-less auth and preserve inherite…
kriszyp Jun 8, 2026
1a23196
harden transient registryAuth token handling on deploy_component
kriszyp Jun 8, 2026
9d46b36
Merge remote-tracking branch 'origin/main' into kris/registry-deploy-…
kriszyp Jun 8, 2026
4e90560
guard registryAuth against .npmrc line injection; clear in-memory token
kriszyp Jun 8, 2026
e6b5a5d
Merge remote-tracking branch 'origin/main' into kris/registry-deploy-…
kriszyp Jun 8, 2026
091be8e
Merge remote-tracking branch 'origin/main' into kris/registry-deploy-…
kriszyp Jun 9, 2026
69f2549
Merge remote-tracking branch 'origin/main' into kris/registry-deploy-…
Jul 1, 2026
86514db
feat: two-tier component secret delivery + env declarations (#1550)
Jul 2, 2026
b3a26f9
fix: address Codex review findings on secret delivery
Jul 2, 2026
58444a6
fix: drop cycle-level declaration reset (main-thread reload never rep…
Jul 2, 2026
71e29f1
fix: address Gemini review findings on secret delivery
Jul 2, 2026
52acd79
fix: make secrets proxy enumeration traps inspect-safe outside a bind…
Jul 3, 2026
802d351
refactor: move SecretsView type into componentSecrets, import in index
Jul 6, 2026
d65a538
feat(secrets): key the process.env delivery tier on the processEnv fl…
Jul 6, 2026
30276eb
Merge remote-tracking branch 'origin/kris/component-secrets-1550' int…
Jul 8, 2026
9a69b30
feat: resolve deploy_component registryAuth from the hdb_secret store…
Jul 8, 2026
a6865e8
feat: persist provided registry tokens as hdb_secret refs; resolve on…
Jul 8, 2026
f99324e
style: prettier formatting for registry-auth changes
Jul 8, 2026
1e62d71
fix: address review — npmrc newline invariant, 5xx for server-state r…
Jul 8, 2026
87a03fe
refactor(components): hoist deploy class to top-level scope (#1717)
Jul 9, 2026
df6c34d
Merge pull request #1717 from HarperFast/kris/registry-deploy-auth-se…
kriszyp Jul 10, 2026
e708e7c
style: fix prettier 3.9 formatting in Application.ts
Jul 10, 2026
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
195 changes: 188 additions & 7 deletions components/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
writeFile,
} from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { createReadStream, existsSync, readdirSync } from 'node:fs';
import { Readable } from 'node:stream';
Expand All @@ -37,6 +38,10 @@ interface ApplicationConfig {
timeout?: number;
allowInstallScripts?: boolean;
};
// Private-registry auth in reference form only — each entry names an hdb_secret row, never a
// token. Recorded by deploy_component so every (cold) install — reboot, new peer, rollback —
// re-resolves the credential from the store rather than needing it re-supplied.
registryAuth?: { registry: string; secret: string; scope?: string }[];
// an application config can have other arbitrary properties
[key: string]: unknown;
}
Expand Down Expand Up @@ -73,6 +78,22 @@ export class InvalidInstallTimeoutError extends TypeError {
}
}

export class InvalidRegistryAuthPropertyError extends TypeError {
constructor(applicationName: string, registryAuth: unknown) {
super(
`Invalid 'registryAuth' property for application ${applicationName}: expected array, got ${typeof registryAuth}`
);
}
}

export class InvalidRegistryAuthEntryError extends TypeError {
constructor(applicationName: string) {
super(
`Invalid 'registryAuth' entry for application ${applicationName}: expected { registry, secret, scope? } reference`
);
}
}

export function assertApplicationConfig(
applicationName: string,
applicationConfig: Record<'package', unknown> & Record<string, unknown>
Expand Down Expand Up @@ -110,6 +131,24 @@ export function assertApplicationConfig(
);
}
}
if ('registryAuth' in applicationConfig && applicationConfig.registryAuth !== undefined) {
const entries = applicationConfig.registryAuth;
if (!Array.isArray(entries)) {
throw new InvalidRegistryAuthPropertyError(applicationName, entries);
}
for (const entry of entries) {
// Config carries references only — a literal `token` here would mean a plaintext credential
// was persisted to disk, which the deploy path is designed to prevent.
if (
typeof entry !== 'object' ||
entry === null ||
typeof (entry as any).registry !== 'string' ||
typeof (entry as any).secret !== 'string'
) {
throw new InvalidRegistryAuthEntryError(applicationName);
}
}
}
}

/**
Expand Down Expand Up @@ -210,7 +249,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 @@ -354,7 +396,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 @@ -414,7 +457,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 @@ -470,7 +514,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 @@ -506,6 +551,7 @@ interface ApplicationOptions {
packageIdentifier?: string;
install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
onInstallLine?: OnInstallLine;
registryAuth?: RegistryAuthEntry[];
}

export class Application {
Expand All @@ -517,19 +563,75 @@ 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');
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;
// Drop the in-memory token array too, so it can't surface in a later heap dump or error
// serialization of this Application instance.
this.registryAuth = undefined;
}
}
}

/**
Expand Down Expand Up @@ -569,9 +671,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 @@ -631,10 +737,29 @@ export async function installApplications() {
// This will throw if the config is invalid
assertApplicationConfig(name, applicationConfig);

// Resolve any private-registry auth references from the store so a cold install (fresh
// node, wiped components dir, new peer that never installed) can authenticate without the
// token being re-supplied. Best-effort: if custody isn't available yet or a referenced
// secret is missing, log and install without it (a truly private package then fails in
// npm with its own error) rather than blocking boot.
let resolvedRegistryAuth: RegistryAuthEntry[] | undefined;
if (applicationConfig.registryAuth?.length) {
try {
const { resolveRegistryAuth } = await import('./secretOperations.ts');
resolvedRegistryAuth = (await resolveRegistryAuth(applicationConfig.registryAuth, name)) as
RegistryAuthEntry[] | undefined;
} catch (error) {
logger.warn?.(
`Could not resolve registryAuth for application ${name} at install time: ${(error as Error).message}`
);
}
}

const application = new Application({
name,
packageIdentifier: applicationConfig.package,
install: applicationConfig.install,
registryAuth: resolvedRegistryAuth,
});

// Lock check: only install if not already installed with matching configuration
Expand Down Expand Up @@ -675,6 +800,49 @@ 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) {
// Enforce the no-newline invariant at the injection point so it holds for every source. The
// ops validator already rejects CR/LF in a literal `token`, but a token resolved from an
// hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
// arbitrary .npmrc lines (admin-only per the threat model, but the literal path already
// defends this class).
if (/[\r\n]/.test(token)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): the comment says "Enforce the no-newline invariant at the injection point so it holds for every source," but only token is guarded — registry is not. A manually-edited config or a hypothetical bypass path with an embedded \n in the registry string would survive normalizeRegistryUrl's .trim() and inject extra .npmrc lines (e.g. https://host/\nregistry=https://evil.com/ → an injected registry= line). Apply the same guard to registry:

Suggested change
if (/[\r\n]/.test(token)) {
if (/[\r\n]/.test(token)) {
throw new Error(`registry auth token for '${registry}' contains an illegal newline character`);
}
if (/[\r\n]/.test(registry)) {
throw new Error(`registry URL for token entry contains an illegal newline character`);
}

throw new Error(`registry auth token for '${registry}' contains an illegal newline character`);
}
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 @@ -734,7 +902,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 @@ -748,6 +917,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
3 changes: 3 additions & 0 deletions components/ApplicationScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export class MissingDefaultFilesOptionError extends Error {
* This class is used to represent the application scope for the VM context used for loading modules within an application
*/
export class ApplicationScope {
/** Component identity (application directory name) — what secret grants are matched against. */
name: string;
logger: any;
resources: Resources;
server: Server;
Expand All @@ -25,6 +27,7 @@ export class ApplicationScope {
config: any;
moduleCache: any; // used by the loader to retain a cache of modules, type is an internal detail of the loader
constructor(name: string, resources: Resources, server: Server, isInternal = false) {
this.name = name;
this.logger = forComponent(name, !isInternal);

this.resources = resources;
Expand Down
12 changes: 12 additions & 0 deletions components/Scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { FileAndURLPathConfig } from './Component.ts';
import { FilesOption } from './deriveGlobOptions.ts';
import { requestRestart } from './requestRestart.ts';
import { ApplicationScope } from './ApplicationScope.ts';
import { getSecretsForComponent } from './componentSecrets.ts';
import { deployLifecycle } from './deployLifecycle.ts';

export class MissingDefaultFilesOptionError extends Error {
Expand Down Expand Up @@ -143,6 +144,17 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
return this.#logger;
}

/**
* The application's secrets view (#1550): hdb_secret rows granted to this application plus its
* declared global-tier env names. Frozen, enumerable, values decrypted at component load.
* Keyed by the ApplicationScope's name (the application directory name — the identity grants
* and env declarations use, and the same binding `import { secrets } from 'harper'` resolves);
* `#appName` can differ on paths like RUN_HDB_APP, where it is the full directory path.
*/
get secrets(): Readonly<Record<string, string>> {
return getSecretsForComponent(this.applicationScope?.name ?? this.#appName);
}

get appName(): string {
return this.#appName;
}
Expand Down
Loading
Loading