Skip to content

Commit e8efacd

Browse files
kriszypclaudeKris Zyp
authored
Two-tier component secret delivery + env declarations (#1550) (#1582)
* feat: transient private-registry auth for deploy_component Add an optional `registryAuth` array to deploy_component carrying private npm registry tokens. The deploying node materializes a per-deploy 0600 `.npmrc` (in a 0700 temp dir) that `npm pack`/`npm install` authenticate against, then removes it; the token is held only in memory and that transient file. The token is stripped from the request before replication and from the operations log, so it never persists to config, hdb_deployment, the replication channel, or logs. Peers reinstall the package via their own fabric-injected NPM_CONFIG_USERCONFIG. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: route default registry for scope-less auth and preserve inherited npmrc Addresses two Codex review findings on the transient registry-auth path: - A scope-less registryAuth entry now also emits a default `registry=` line so an unscoped package spec (npm:my-private-app) and its transitive deps resolve against the supplied private registry instead of silently falling back to npmjs (the token would otherwise never be used). Scoped entries still route only their @scope. - writeTransientNpmrc now prepends any inherited npm_config_userconfig (e.g. a fabric-injected file with cluster registries, proxy, or cafile) and appends the transient auth last so it wins on conflict, instead of clobbering those settings. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * harden transient registryAuth token handling on deploy_component Address cross-model (gemini) review findings on the private-registry deploy auth path, all defense-in-depth for the invariant that the token never survives into a log/error path or replication: - operations.js: strip req.registryAuth immediately after the Application ctor captures it, instead of after loadComponent. The prior strip ran only on the success path, leaking the token in req if prepareApplication/loadComponent threw. Removes the now-redundant later delete. - Application.cleanupTransientNpmrc: wrap rm in try/catch so a failure (e.g. a Windows file lock) can't mask the original deploy error or skip broadcastDeployEnd; state is always cleared in finally. - Application.writeTransientNpmrc: clean up a prior temp dir if called twice, so the earlier 0700 dir + token file isn't leaked. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * guard registryAuth against .npmrc line injection; clear in-memory token Address claude[bot] review findings on the private-registry deploy auth path: - operationsValidation.js: forbid CR/LF in registry and token. Both are written verbatim into the transient line-based .npmrc, so a super_user could otherwise inject arbitrary npm config lines (redirect scopes/registries, set other keys). Uses a newline guard rather than a strict URI validator because registry also accepts bare hosts and //host/ forms. Adds tests for both injection paths plus a bare-host case to pin that the guard doesn't over-restrict. - Application.cleanupTransientNpmrc: also clear this.registryAuth so the plaintext token array can't surface in a later heap dump or error serialization of the Application instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: two-tier component secret delivery + env declarations (#1550) Consumption side of the hdb_secret store (#1554): rows with empty grants are decrypted and materialized into the real process.env before components load (pre-existing real env wins); rows with non-empty grants never touch process.env and are exposed only through the per-component secrets accessor (import { secrets } from 'harper' / scope.secrets), which also carries the component's declared global-tier names. Component configs declare env expectations in an `env:` block (string = inline literal with .env semantics incl. enc:v1:; object = declaration satisfied from the store). Unsatisfied required declarations gate that component's load (missing | ungranted | custody-unavailable) while the instance keeps running, and the declared-but-unsatisfied set is exposed (metadata only) via get_components. Under the vm/compartment loaders the accessor binds exactly via the per-scope harper module; under the native loader the process-wide export resolves through a component-load AsyncLocalStorage context and fails loudly from ambiguous contexts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address Codex review findings on secret delivery - gate failure no longer installs an ErrorResource at '/' (could clobber the root URL space of unrelated components); containment is status + log + absent URL space - env literals apply only after the whole block validates and the gate passes (no partial process.env mutation from a gated component) - load cycles reset per-component declaration state so removed components/env blocks don't leave stale accessor names or stale unsatisfiedEnv in get_components - scope.secrets keys by ApplicationScope.name (grants identity), not Scope#appName, which diverges on RUN_HDB_APP paths - env-declaring component loads refresh the store snapshot, so deploy validation in a long-lived worker gates against post-boot set_secret/grant_secret changes - secrets proxy rejects preventExtensions/setPrototypeOf (freezing the shared proxy would break key-set invariants for all later consumers) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: drop cycle-level declaration reset (main-thread reload never reprocesses) loadedPaths is never cleared in production, so already-loaded components early-return on main-thread reload cycles — a cycle-level registry wipe would permanently empty get_components' unsatisfiedEnv after the first reload. Registries are overwrite-on-reprocess instead; deleted components' state is unreachable from get_components. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address Gemini review findings on secret delivery - single-flight materializeGlobalSecrets: concurrent component loads share one hdb_secret scan (no N-way scan/decrypt herd at boot) and two scans can never interleave (older scan can't overwrite newer state); sequential callers still get fresh reads - gate accepts a real env var fallback for a granted-but-undecryptable row, matching what the accessor would serve at runtime - accessor views are null-prototype so Object.prototype names can't masquerade as secret values under dynamic access - document that env literals share process.env cross-component (load- order visibility, same as .env today) - regression test: component binding propagates into native-loader ESM top-level evaluation (incl. destructure + post-top-level-await) — empirical guard for the ALS/dynamic-import claim (dismissed on evidence for our supported Node range) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: make secrets proxy enumeration traps inspect-safe outside a binding context has/ownKeys/getOwnPropertyDescriptor now report an empty object when no component-load binding is active, so inspectors/serializers (util.inspect, spread, `in`) can never crash the process; direct property reads stay loud. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: move SecretsView type into componentSecrets, import in index Addresses Dawson's review comment: the per-component secrets view type was defined inline in index.ts; export it as SecretsView from componentSecrets.ts (next to the accessor it describes) and import it as a type in index.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(secrets): key the process.env delivery tier on the processEnv flag (consumption) Consumption side of the explicit-tier change (#1582): materialize a row into the real process.env when `processEnv: true` rather than when `grants` is empty, and key the declaration gate + accessor on the same flag. A row with neither processEnv nor grants is now inert (visible to no component) until granted — omission reads as restrictive, matching the review consensus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: resolve deploy_component registryAuth from the hdb_secret store (reference, not embed) registryAuth entries may now name an hdb_secret row (`{ registry, secret }`) instead of carrying a literal `{ registry, token }`. The token is resolved by decrypting the referenced row on the deploying (main) thread, where the operations API dispatches and the Pro secrets component registers custody — so the credential lives in the replicated, audited secrets store (#1550/#1582) rather than travelling in the operation body. - operationsValidation: each entry is `token` XOR `secret` (secret uses the set_secret name grammar); the literal-token form is unchanged. - secretOperations.resolveRegistryAuth(): decrypts secret-backed entries; literal tokens pass through untouched (no custody/store needed on that fast path). Authority mirrors the accessor model — the secret must be processEnv-global or granted to the component being deployed, else 403; missing row → 404; absent custody or a decrypt failure fails the deploy loudly. - operations.deployComponent: resolves before constructing the Application; the resolved token gets the same transient handling as a literal token (transient .npmrc, stripped from req before replication, kept out of the ops log). Peers still authenticate via fabric-injected NPM_CONFIG_USERCONFIG; origin-side resolution keeps cluster behavior identical. Peer-side ref resolution (each peer decrypting its replicated hdb_secret copy) is a possible follow-up. Unit coverage: resolveRegistryAuth pass-through/resolve/global/mixed + all four failure reasons; validator token-XOR-secret cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: persist provided registry tokens as hdb_secret refs; resolve on every install Completes the reference-not-embed story: a provided registry token is no longer a transient, this-node-only credential — it is ingested into the encrypted, replicated hdb_secret store and referenced everywhere, so package deploys survive rollback, reboot, and new peers without the operator re-supplying the token. - ingestRegistryAuth() (secretOperations): a literal `{ registry, token }` is sealed into hdb_secret (via set_secret, custody required) under a derived name (deploy.<component>.<registry>) granted to the component, and returned as a `{ registry, secret }` reference. Idempotent on rotation. Already-reference entries and (no-custody) literal tokens pass through untouched. - deployComponent: ingests up front, then records references in TWO durable places — the component config (applicationConfig.registryAuth, read on every cold install) and the hdb_deployment row (registry_auth, the rollback source). req.registryAuth now replicates as references, never tokens; peers resolve from their own replicated hdb_secret copy (resolveRegistryAuth gains a bounded waitMs to cover the row arriving just behind the deploy op). No-custody core stays on the transient #1158 fallback. - installApplications(): resolves applicationConfig.registryAuth at cold install so a fresh/wiped node or new peer authenticates from the store (best-effort — logs and installs without auth if custody isn't up yet, rather than blocking boot). - assertApplicationConfig + deployment row + config type: registryAuth is references only; a literal token on disk is rejected. Fabric NPM_CONFIG_USERCONFIG injection (harper-pro) becomes redundant for auth once peers resolve from the store — a coordinated harper-pro follow-up removes the token injection; core keeps userconfig *inheritance* for non-auth npm config (proxy/cafile). Tests: ingest seal/round-trip/passthrough/no-custody/idempotent, derived-name sanitization, resolve bounded-wait (times-out-404 + replicates-in-mid-wait), assertApplicationConfig references-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: prettier formatting for registry-auth changes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review — npmrc newline invariant, 5xx for server-state resolve failures Addresses PR #1717 review comments: - buildNpmrcContent: enforce the no-CR/LF invariant at the .npmrc write boundary so it holds for tokens resolved from hdb_secret rows, not just validator-guarded literal tokens (cb1kenobi/Barber AI). - resolveRegistryAuth: report no-custody as 503 and decrypt failure as 500 instead of the ClientError default 400 — these are server-state, not client-fixable (cb1kenobi/Barber AI). - resolveRegistryAuth: skip null entries for parity with the fast-path guard (gemini-code-assist; no 'SKIP' sentinel exists in this path). - Tests: newline-guard rejection + status-code assertions on the two server-state failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(components): hoist deploy class to top-level scope (#1717) Hoist the throwaway InvalidRegistryAuthError classes (defined inline on every assertApplicationConfig call) to named top-level classes, InvalidRegistryAuthPropertyError and InvalidRegistryAuthEntryError, matching the existing InvalidInstall*Error convention in the file. Also fixes prettier formatting on secretOperations.test.js flagged by CI. * style: fix prettier 3.9 formatting in Application.ts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Kris Zyp <kris@harperdb.io>
1 parent bd1dce0 commit e8efacd

19 files changed

Lines changed: 2037 additions & 15 deletions

components/Application.ts

Lines changed: 188 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
writeFile,
2121
} from 'node:fs/promises';
2222
import { spawn } from 'node:child_process';
23+
import { tmpdir } from 'node:os';
2324
import { randomUUID } from 'node:crypto';
2425
import { createReadStream, existsSync, readdirSync } from 'node:fs';
2526
import { Readable } from 'node:stream';
@@ -37,6 +38,10 @@ interface ApplicationConfig {
3738
timeout?: number;
3839
allowInstallScripts?: boolean;
3940
};
41+
// Private-registry auth in reference form only — each entry names an hdb_secret row, never a
42+
// token. Recorded by deploy_component so every (cold) install — reboot, new peer, rollback —
43+
// re-resolves the credential from the store rather than needing it re-supplied.
44+
registryAuth?: { registry: string; secret: string; scope?: string }[];
4045
// an application config can have other arbitrary properties
4146
[key: string]: unknown;
4247
}
@@ -73,6 +78,22 @@ export class InvalidInstallTimeoutError extends TypeError {
7378
}
7479
}
7580

81+
export class InvalidRegistryAuthPropertyError extends TypeError {
82+
constructor(applicationName: string, registryAuth: unknown) {
83+
super(
84+
`Invalid 'registryAuth' property for application ${applicationName}: expected array, got ${typeof registryAuth}`
85+
);
86+
}
87+
}
88+
89+
export class InvalidRegistryAuthEntryError extends TypeError {
90+
constructor(applicationName: string) {
91+
super(
92+
`Invalid 'registryAuth' entry for application ${applicationName}: expected { registry, secret, scope? } reference`
93+
);
94+
}
95+
}
96+
7697
export function assertApplicationConfig(
7798
applicationName: string,
7899
applicationConfig: Record<'package', unknown> & Record<string, unknown>
@@ -110,6 +131,24 @@ export function assertApplicationConfig(
110131
);
111132
}
112133
}
134+
if ('registryAuth' in applicationConfig && applicationConfig.registryAuth !== undefined) {
135+
const entries = applicationConfig.registryAuth;
136+
if (!Array.isArray(entries)) {
137+
throw new InvalidRegistryAuthPropertyError(applicationName, entries);
138+
}
139+
for (const entry of entries) {
140+
// Config carries references only — a literal `token` here would mean a plaintext credential
141+
// was persisted to disk, which the deploy path is designed to prevent.
142+
if (
143+
typeof entry !== 'object' ||
144+
entry === null ||
145+
typeof (entry as any).registry !== 'string' ||
146+
typeof (entry as any).secret !== 'string'
147+
) {
148+
throw new InvalidRegistryAuthEntryError(applicationName);
149+
}
150+
}
151+
}
113152
}
114153

115154
/**
@@ -210,7 +249,10 @@ export async function extractApplication(application: Application) {
210249
application.name,
211250
'npm',
212251
['pack', '--json', application.packageIdentifier],
213-
parentDirPath
252+
parentDirPath,
253+
undefined,
254+
undefined,
255+
application.npmUserconfigPath
214256
);
215257
if (code !== 0) {
216258
if (isSSHAuthFailure(stderr)) {
@@ -354,7 +396,8 @@ export async function installApplication(application: Application) {
354396
args,
355397
application.dirPath,
356398
application.install?.timeout,
357-
customOnLine
399+
customOnLine,
400+
application.npmUserconfigPath
358401
);
359402
// if it succeeds, return
360403
if (code === 0) {
@@ -414,7 +457,8 @@ export async function installApplication(application: Application) {
414457
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
415458
application.dirPath,
416459
application.install?.timeout,
417-
pmOnLine
460+
pmOnLine,
461+
application.npmUserconfigPath
418462
);
419463

420464
// if it succeeds, return
@@ -470,7 +514,8 @@ export async function installApplication(application: Application) {
470514
npmInstallArgs,
471515
application.dirPath,
472516
application.install?.timeout,
473-
npmOnLine
517+
npmOnLine,
518+
application.npmUserconfigPath
474519
);
475520

476521
// if it succeeds, return
@@ -506,6 +551,7 @@ interface ApplicationOptions {
506551
packageIdentifier?: string;
507552
install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
508553
onInstallLine?: OnInstallLine;
554+
registryAuth?: RegistryAuthEntry[];
509555
}
510556

511557
export class Application {
@@ -517,19 +563,75 @@ export class Application {
517563
dirPath: string;
518564
logger: Logger;
519565
packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".
520-
521-
constructor({ name, payload, packageIdentifier, install, onInstallLine }: ApplicationOptions) {
566+
// Transient registry auth provided by a deploy. The token is held only in memory and a
567+
// per-deploy `.npmrc`; it is never persisted to config, hdb_deployment, or replicated.
568+
registryAuth?: RegistryAuthEntry[];
569+
// Path to the per-deploy `.npmrc`, set by writeTransientNpmrc() during prepareApplication and
570+
// passed to the spawn calls; undefined when no registry auth was provided.
571+
npmUserconfigPath?: string;
572+
#npmrcTempDir?: string;
573+
574+
constructor({ name, payload, packageIdentifier, install, onInstallLine, registryAuth }: ApplicationOptions) {
522575
this.name = name;
523576
this.payload = payload;
524577
this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
525578
this.install = install;
526579
this.onInstallLine = onInstallLine;
580+
this.registryAuth = registryAuth;
527581
const componentsRoot = getConfigPath(CONFIG_PARAMS.COMPONENTSROOT);
528582
if (!componentsRoot) throw new Error('componentsRoot is not configured');
529583
this.dirPath = join(componentsRoot, name);
530584
this.logger = logger.loggerWithTag(name);
531585
this.packageManagerPrefix = getConfigValue(CONFIG_PARAMS.APPLICATIONS_PACKAGEMANAGERPREFIX);
532586
}
587+
588+
// Write the transient `.npmrc` into a fresh 0700 temp dir (file mode 0600) and record its path
589+
// so the deploy's npm spawns authenticate against the private registry. No-op without registry auth.
590+
//
591+
// Because `nonInteractiveSpawn` points npm at this single file (replacing any inherited
592+
// npm_config_userconfig), prepend the contents of an already-configured userconfig — e.g. a
593+
// fabric-injected file carrying cluster registries, a proxy, or a cafile — so those settings
594+
// survive. The transient auth is appended last so it wins on conflict (npm honors the last
595+
// value for a given key).
596+
async writeTransientNpmrc(): Promise<void> {
597+
if (!this.registryAuth?.length) return;
598+
// Defensive: if called more than once, remove the prior temp dir first so it isn't leaked.
599+
if (this.#npmrcTempDir) await this.cleanupTransientNpmrc();
600+
this.#npmrcTempDir = await mkdtemp(join(tmpdir(), 'harper-npmrc-'));
601+
const npmrcPath = join(this.#npmrcTempDir, '.npmrc');
602+
let content = '';
603+
const inheritedUserconfig = process.env.npm_config_userconfig ?? process.env.NPM_CONFIG_USERCONFIG;
604+
if (inheritedUserconfig) {
605+
try {
606+
const inherited = await readFile(inheritedUserconfig, 'utf8');
607+
content = inherited.endsWith('\n') ? inherited : inherited + '\n';
608+
} catch (error: any) {
609+
// Missing inherited file is fine (npm would have created/ignored it); surface anything else.
610+
if (error?.code !== 'ENOENT') throw error;
611+
}
612+
}
613+
content += buildNpmrcContent(this.registryAuth);
614+
await writeFile(npmrcPath, content, { mode: 0o600 });
615+
this.npmUserconfigPath = npmrcPath;
616+
}
617+
618+
// Remove the transient `.npmrc` (and its temp dir) once the deploy's npm work is done.
619+
async cleanupTransientNpmrc(): Promise<void> {
620+
if (!this.#npmrcTempDir) return;
621+
try {
622+
await rm(this.#npmrcTempDir, { recursive: true, force: true });
623+
} catch (error) {
624+
// Called from prepareApplication's finally; a throw here (e.g. a Windows file lock) would
625+
// mask the original deploy error and skip broadcastDeployEnd. Log and always clear state.
626+
this.logger.warn(`Failed to remove transient .npmrc dir ${this.#npmrcTempDir}:`, error);
627+
} finally {
628+
this.#npmrcTempDir = undefined;
629+
this.npmUserconfigPath = undefined;
630+
// Drop the in-memory token array too, so it can't surface in a later heap dump or error
631+
// serialization of this Application instance.
632+
this.registryAuth = undefined;
633+
}
634+
}
533635
}
534636

535637
/**
@@ -569,9 +671,13 @@ export function derivePackageIdentifier(packageIdentifier: string) {
569671
export async function prepareApplication(application: Application) {
570672
await broadcastDeployStart(application.name);
571673
try {
674+
// Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
675+
// `npm install` authenticate against the private registry; always remove it afterward.
676+
await application.writeTransientNpmrc();
572677
await extractApplication(application);
573678
await installApplication(application);
574679
} finally {
680+
await application.cleanupTransientNpmrc();
575681
broadcastDeployEnd(application.name);
576682
}
577683
}
@@ -631,10 +737,29 @@ export async function installApplications() {
631737
// This will throw if the config is invalid
632738
assertApplicationConfig(name, applicationConfig);
633739

740+
// Resolve any private-registry auth references from the store so a cold install (fresh
741+
// node, wiped components dir, new peer that never installed) can authenticate without the
742+
// token being re-supplied. Best-effort: if custody isn't available yet or a referenced
743+
// secret is missing, log and install without it (a truly private package then fails in
744+
// npm with its own error) rather than blocking boot.
745+
let resolvedRegistryAuth: RegistryAuthEntry[] | undefined;
746+
if (applicationConfig.registryAuth?.length) {
747+
try {
748+
const { resolveRegistryAuth } = await import('./secretOperations.ts');
749+
resolvedRegistryAuth = (await resolveRegistryAuth(applicationConfig.registryAuth, name)) as
750+
RegistryAuthEntry[] | undefined;
751+
} catch (error) {
752+
logger.warn?.(
753+
`Could not resolve registryAuth for application ${name} at install time: ${(error as Error).message}`
754+
);
755+
}
756+
}
757+
634758
const application = new Application({
635759
name,
636760
packageIdentifier: applicationConfig.package,
637761
install: applicationConfig.install,
762+
registryAuth: resolvedRegistryAuth,
638763
});
639764

640765
// Lock check: only install if not already installed with matching configuration
@@ -675,6 +800,49 @@ function getGitSSHCommand() {
675800
}
676801
}
677802

803+
export interface RegistryAuthEntry {
804+
registry: string;
805+
token: string;
806+
scope?: string;
807+
}
808+
809+
// Normalize a registry to a full URL with a scheme and trailing slash, e.g.
810+
// `npm.pkg.github.com` or `//npm.pkg.github.com` → `https://npm.pkg.github.com/`.
811+
function normalizeRegistryUrl(registry: string): string {
812+
let url = registry.trim();
813+
if (!/^https?:\/\//i.test(url)) {
814+
url = url.startsWith('//') ? `https:${url}` : `https://${url}`;
815+
}
816+
if (!url.endsWith('/')) url += '/';
817+
return url;
818+
}
819+
820+
// Build the contents of a transient `.npmrc` from registry auth entries: an auth-token line keyed
821+
// by npm's registry auth key (scheme stripped, leading `//`, trailing `/`) plus a registry-routing
822+
// line. A scope routes only that `@scope` to the registry (`@scope:registry=…`); without a scope
823+
// the entry sets npm's default `registry=…` so an unscoped package spec (e.g. `npm:my-private-app`)
824+
// or its transitive deps actually resolve against this registry rather than the public default.
825+
// A scope-less entry therefore requires its registry to serve/proxy whatever npm needs to install;
826+
// with multiple scope-less entries npm's last-value-wins applies to the default `registry`.
827+
export function buildNpmrcContent(registryAuth: RegistryAuthEntry[]): string {
828+
const lines: string[] = [];
829+
for (const { registry, token, scope } of registryAuth) {
830+
// Enforce the no-newline invariant at the injection point so it holds for every source. The
831+
// ops validator already rejects CR/LF in a literal `token`, but a token resolved from an
832+
// hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
833+
// arbitrary .npmrc lines (admin-only per the threat model, but the literal path already
834+
// defends this class).
835+
if (/[\r\n]/.test(token)) {
836+
throw new Error(`registry auth token for '${registry}' contains an illegal newline character`);
837+
}
838+
const registryUrl = normalizeRegistryUrl(registry);
839+
const authKey = registryUrl.replace(/^https?:/i, '');
840+
lines.push(`${authKey}:_authToken=${token}`);
841+
lines.push(scope ? `${scope}:registry=${registryUrl}` : `registry=${registryUrl}`);
842+
}
843+
return lines.join('\n') + '\n';
844+
}
845+
678846
/**
679847
* Execute a command (using `spawn`) with stdin ignored.
680848
*
@@ -734,7 +902,8 @@ export function nonInteractiveSpawn(
734902
args: string[],
735903
cwd: string,
736904
timeoutMs: number = 60 * 60 * 1000,
737-
onLine?: (stream: 'stdout' | 'stderr', line: string) => void
905+
onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
906+
npmUserconfigPath?: string
738907
): Promise<{ stdout: string; stderr: string; code: number }> {
739908
return new Promise((resolve, reject) => {
740909
logger
@@ -748,6 +917,18 @@ export function nonInteractiveSpawn(
748917
env.GIT_SSH_COMMAND = gitSSHCommand;
749918
}
750919

920+
// A deploy carrying transient registry auth points npm at a per-deploy `.npmrc` so
921+
// `npm pack`/`install` can authenticate against a private registry without the token
922+
// ever touching disk durably, the package reference, config, or hdb_deployment.
923+
if (npmUserconfigPath) {
924+
// On case-insensitive platforms (Windows) an inherited NPM_CONFIG_USERCONFIG would
925+
// shadow the lowercase key we set, so drop any existing case variant first.
926+
for (const key of Object.keys(env)) {
927+
if (key.toLowerCase() === 'npm_config_userconfig') delete env[key];
928+
}
929+
env.npm_config_userconfig = npmUserconfigPath;
930+
}
931+
751932
if (process.platform === 'win32' && command === 'npm') {
752933
command = 'npm.cmd';
753934
}

components/ApplicationScope.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export class MissingDefaultFilesOptionError extends Error {
1616
* This class is used to represent the application scope for the VM context used for loading modules within an application
1717
*/
1818
export class ApplicationScope {
19+
/** Component identity (application directory name) — what secret grants are matched against. */
20+
name: string;
1921
logger: any;
2022
resources: Resources;
2123
server: Server;
@@ -25,6 +27,7 @@ export class ApplicationScope {
2527
config: any;
2628
moduleCache: any; // used by the loader to retain a cache of modules, type is an internal detail of the loader
2729
constructor(name: string, resources: Resources, server: Server, isInternal = false) {
30+
this.name = name;
2831
this.logger = forComponent(name, !isInternal);
2932

3033
this.resources = resources;

components/Scope.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { FilesOption } from './deriveGlobOptions.ts';
1212
import { requestRestart } from './requestRestart.ts';
1313
import { resolveBaseURLPath } from './resolveBaseURLPath.ts';
1414
import { ApplicationScope } from './ApplicationScope.ts';
15+
import { getSecretsForComponent } from './componentSecrets.ts';
1516
import { deployLifecycle } from './deployLifecycle.ts';
1617

1718
export class MissingDefaultFilesOptionError extends Error {
@@ -146,6 +147,17 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
146147
return this.#logger;
147148
}
148149

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

0 commit comments

Comments
 (0)