Skip to content

Commit a6865e8

Browse files
Kris Zypclaude
andcommitted
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>
1 parent 9a69b30 commit a6865e8

6 files changed

Lines changed: 313 additions & 25 deletions

File tree

components/Application.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ interface ApplicationConfig {
3838
timeout?: number;
3939
allowInstallScripts?: boolean;
4040
};
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 }[];
4145
// an application config can have other arbitrary properties
4246
[key: string]: unknown;
4347
}
@@ -111,6 +115,28 @@ export function assertApplicationConfig(
111115
);
112116
}
113117
}
118+
if ('registryAuth' in applicationConfig && applicationConfig.registryAuth !== undefined) {
119+
const entries = applicationConfig.registryAuth;
120+
if (!Array.isArray(entries)) {
121+
throw new (class InvalidRegistryAuthError extends TypeError {})(
122+
`Invalid 'registryAuth' property for application ${applicationName}: expected array, got ${typeof entries}`
123+
);
124+
}
125+
for (const entry of entries) {
126+
// Config carries references only — a literal `token` here would mean a plaintext credential
127+
// was persisted to disk, which the deploy path is designed to prevent.
128+
if (
129+
typeof entry !== 'object' ||
130+
entry === null ||
131+
typeof (entry as any).registry !== 'string' ||
132+
typeof (entry as any).secret !== 'string'
133+
) {
134+
throw new (class InvalidRegistryAuthError extends TypeError {})(
135+
`Invalid 'registryAuth' entry for application ${applicationName}: expected { registry, secret, scope? } reference`
136+
);
137+
}
138+
}
139+
}
114140
}
115141

116142
/**
@@ -699,10 +725,30 @@ export async function installApplications() {
699725
// This will throw if the config is invalid
700726
assertApplicationConfig(name, applicationConfig);
701727

728+
// Resolve any private-registry auth references from the store so a cold install (fresh
729+
// node, wiped components dir, new peer that never installed) can authenticate without the
730+
// token being re-supplied. Best-effort: if custody isn't available yet or a referenced
731+
// secret is missing, log and install without it (a truly private package then fails in
732+
// npm with its own error) rather than blocking boot.
733+
let resolvedRegistryAuth: RegistryAuthEntry[] | undefined;
734+
if (applicationConfig.registryAuth?.length) {
735+
try {
736+
const { resolveRegistryAuth } = await import('./secretOperations.ts');
737+
resolvedRegistryAuth = (await resolveRegistryAuth(applicationConfig.registryAuth, name)) as
738+
| RegistryAuthEntry[]
739+
| undefined;
740+
} catch (error) {
741+
logger.warn?.(
742+
`Could not resolve registryAuth for application ${name} at install time: ${(error as Error).message}`
743+
);
744+
}
745+
}
746+
702747
const application = new Application({
703748
name,
704749
packageIdentifier: applicationConfig.package,
705750
install: applicationConfig.install,
751+
registryAuth: resolvedRegistryAuth,
706752
});
707753

708754
// Lock check: only install if not already installed with matching configuration

components/deploymentRecorder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Readable, Transform, pipeline } from 'node:stream';
1515
import { databases } from '../resources/databases.ts';
1616
import { createBlob, isSaving, deleteBlob } from '../resources/blob.ts';
1717
import * as terms from '../utility/hdbTerms.ts';
18+
import type { RegistryAuthReference } from './secretOperations.ts';
1819
import { ClientError } from '../utility/errors/hdbError.ts';
1920
import { logger } from '../utility/logging/logger.ts';
2021
import { hostname } from 'node:os';
@@ -61,6 +62,10 @@ interface CreateOptions {
6162
user?: string;
6263
restart_mode?: 'immediate' | 'rolling' | null;
6364
rollback_of?: string | null;
65+
// Registry-auth in reference form (`{ registry, secret, scope? }`) — never a literal token. Kept
66+
// so a rollback can re-resolve the private-registry credential from hdb_secret without the
67+
// operator re-supplying it. Null when the deploy used no auth or a no-custody transient token.
68+
registry_auth?: RegistryAuthReference[] | null;
6469
emitter?: ProgressEmitter;
6570
}
6671

@@ -98,6 +103,7 @@ export class DeploymentRecorder {
98103
completed_at: null,
99104
user: options.user ?? null,
100105
rollback_of: options.rollback_of ?? null,
106+
registry_auth: options.registry_auth ?? null,
101107
error: null,
102108
};
103109
const recorder = new DeploymentRecorder(deploymentId, record);
@@ -446,7 +452,7 @@ export class DeploymentRecorder {
446452
// take well over the original 30s to arrive on a peer (harper-pro#402). 120s matches the
447453
// blob-stream receive default and gives a loaded cluster room to converge. Override per-deploy
448454
// via the `deployment_timeout` operation parameter.
449-
const DEFAULT_AWAIT_ROW_TIMEOUT_MS = 120_000;
455+
export const DEFAULT_AWAIT_ROW_TIMEOUT_MS = 120_000;
450456

451457
/**
452458
* Peer-side helper — wait for the hdb_deployment row to arrive via table replication,

components/operations.js

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ const { packageDirectory } = require('../components/packageComponent.ts');
2626
const { Resources } = require('../resources/Resources.ts');
2727
const { Application, prepareApplication, ASIDE_STAGING_DIR } = require('./Application.ts');
2828
const { server } = require('../server/Server.ts');
29-
const { DeploymentRecorder, awaitDeploymentRow } = require('./deploymentRecorder.ts');
29+
const {
30+
DeploymentRecorder,
31+
awaitDeploymentRow,
32+
DEFAULT_AWAIT_ROW_TIMEOUT_MS,
33+
} = require('./deploymentRecorder.ts');
3034
const { ProgressEmitter } = require('../server/serverHelpers/progressEmitter.ts');
3135

3236
/**
@@ -370,6 +374,16 @@ async function deployComponent(req) {
370374
throw handleHDBError(validation, validation.message, HTTP_STATUS_CODES.BAD_REQUEST);
371375
}
372376

377+
// Ingest any provided registry token into the secrets store so the credential lives as
378+
// replicated ciphertext (reference, not embed); already-reference entries pass through, and with
379+
// no custody a literal token stays as a transient, this-node-only fallback (#1158). Peers
380+
// re-running a replicated deploy already carry references and never re-ingest.
381+
const { ingestRegistryAuth, resolveRegistryAuth } = require('./secretOperations.ts');
382+
req.registryAuth = await ingestRegistryAuth(req, req.registryAuth, req.project);
383+
// References are safe to persist (config + deployment row) and replicate; a no-custody literal
384+
// token is not — it is used only for this node's install below, then stripped before replication.
385+
const registryAuthReferences = (req.registryAuth ?? []).filter((entry) => entry && entry.secret !== undefined);
386+
373387
// Write to root config if the request contains a package identifier
374388
if (req.package) {
375389
// Check if trying to overwrite a core component (requires force)
@@ -393,6 +407,9 @@ async function deployComponent(req) {
393407
};
394408
}
395409
if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
410+
// Persist registry-auth references (never tokens) so every cold install of this component —
411+
// reboot, new peer, rollback — re-resolves the credential from the store.
412+
if (registryAuthReferences.length) applicationConfig.registryAuth = registryAuthReferences;
396413
await configUtils.addConfig(req.project, applicationConfig);
397414
}
398415

@@ -417,6 +434,8 @@ async function deployComponent(req) {
417434
package_identifier: req.package ?? null,
418435
user: req.hdb_user?.username,
419436
restart_mode: req.restart === 'rolling' ? 'rolling' : req.restart ? 'immediate' : null,
437+
// Reference form only — the rollback source for re-resolving registry auth.
438+
registry_auth: registryAuthReferences.length ? registryAuthReferences : null,
420439
emitter,
421440
});
422441
if (recorder) req._deploymentId = recorder.deploymentId;
@@ -454,13 +473,18 @@ async function deployComponent(req) {
454473
extractionPayload = row.payload_blob.stream();
455474
}
456475

457-
// Resolve any registryAuth entries that reference an hdb_secret row into concrete tokens
458-
// (literal `token` entries pass through unchanged). Runs on the main thread, where deploys
459-
// dispatch and secrets custody is registered; a bad reference (missing/ungranted/undecryptable)
460-
// fails the deploy with a precise error. The resolved tokens stay in-memory for this node's
461-
// npm pack/install and are stripped from req below — same transient handling as a literal token.
462-
const { resolveRegistryAuth } = require('./secretOperations.ts');
463-
const resolvedRegistryAuth = await resolveRegistryAuth(req.registryAuth, req.project);
476+
// Resolve registryAuth references into concrete tokens for this node's npm pack/install
477+
// (a no-custody literal-token fallback passes through unchanged). On a peer running a
478+
// replicated deploy, the referenced hdb_secret row may arrive just behind the deploy op, so
479+
// allow a bounded grace period (same budget as the payload-row wait) for it to replicate in.
480+
let registryAuthWaitMs = 0;
481+
if (isReplicatedExecution) {
482+
const requested = Number(req.deployment_timeout);
483+
registryAuthWaitMs = Number.isFinite(requested) && requested >= 0 ? requested : DEFAULT_AWAIT_ROW_TIMEOUT_MS;
484+
}
485+
const resolvedRegistryAuth = await resolveRegistryAuth(req.registryAuth, req.project, {
486+
waitMs: registryAuthWaitMs,
487+
});
464488

465489
const application = new Application({
466490
name: req.project,
@@ -478,17 +502,16 @@ async function deployComponent(req) {
478502
installCapture.push(manager, stream, line);
479503
if (emitter) emit('install', { manager, stream, line });
480504
},
481-
// Private-registry auth (already resolved above), used here for this node's npm
482-
// pack/install. The Application ctor captures it into application.registryAuth; we strip
483-
// registryAuth from req immediately (below) so neither a literal token nor a secret
484-
// reference is persisted or sent to peers — peers authenticate via their own
485-
// fabric-injected NPM_CONFIG_USERCONFIG.
505+
// Private-registry auth (already resolved above), used here for this node's npm pack/install.
486506
registryAuth: resolvedRegistryAuth,
487507
});
488-
// Strip registryAuth from req immediately after the ctor captures the resolved tokens, so it
489-
// can't survive into an error/log path if prepareApplication or loadComponent throws below
490-
// (the previous strip point after loadComponent only ran on the success path, leaking on failure).
491-
delete req.registryAuth;
508+
// Reduce req.registryAuth to references only (never a token) before it can reach an error/log
509+
// path or replication: references are what peers resolve from their own replicated hdb_secret
510+
// copy; a no-custody literal token is dropped entirely (peers fall back to their fabric-injected
511+
// NPM_CONFIG_USERCONFIG, as before). This also fixes the prior success-only strip that leaked a
512+
// literal token on a prepare/load failure.
513+
if (registryAuthReferences.length) req.registryAuth = registryAuthReferences;
514+
else delete req.registryAuth;
492515

493516
emit('phase', { phase: 'prepare', status: 'start' });
494517
await prepareApplication(application);

components/secretOperations.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,36 +332,120 @@ export interface ResolvedRegistryAuthEntry {
332332
scope?: string;
333333
}
334334

335+
/** A registry-auth entry after ingestion: a reference into hdb_secret, never a token. */
336+
export interface RegistryAuthReference {
337+
registry: string;
338+
secret: string;
339+
scope?: string;
340+
}
341+
342+
/**
343+
* Deterministic name for the auto-minted secret backing a literal registry token: keyed by the
344+
* deploying component and the registry, so re-supplying (or rotating) the token on a later deploy
345+
* overwrites the same row rather than accumulating one per deploy. Sanitized to the set_secret name
346+
* grammar (`\w.-`), since a registry can carry a scheme, port, or path.
347+
*/
348+
export function deriveRegistrySecretName(component: string, registry: string): string {
349+
const registryKey = registry
350+
.trim()
351+
.replace(/^https?:\/\//i, '')
352+
.replace(/^\/\//, '')
353+
.replace(/\/+$/, '')
354+
.toLowerCase()
355+
.replace(/[^\w.-]+/g, '_');
356+
const componentKey = String(component).replace(/[^\w.-]+/g, '_');
357+
return `deploy.${componentKey}.${registryKey}`;
358+
}
359+
360+
/**
361+
* Ingest deploy_component `registryAuth` into the secrets store so a provided registry token lives
362+
* as ciphertext in the replicated, audited `hdb_secret` store (reference, not embed) rather than
363+
* travelling in the operation body. Returns the entries in reference form (`{ registry, secret }`).
364+
*
365+
* - A literal `{ registry, token }` entry is encrypted (via `set_secret`, custody required) under a
366+
* derived name granted to `component`, and returned as a reference. Overwrites an existing
367+
* derived row so token rotation is idempotent.
368+
* - An already-reference `{ registry, secret }` entry passes through unchanged (peers re-running a
369+
* replicated deploy already carry references — they never re-ingest).
370+
* - With no custody on this node (OSS core, or key not held here), literal tokens CANNOT be sealed;
371+
* they pass through untouched so the caller can fall back to the transient, this-node-only path.
372+
* The reference-form result therefore contains only entries the caller may persist and replicate.
373+
*
374+
* Runs on the deploying main thread where custody is registered.
375+
*/
376+
export async function ingestRegistryAuth(req: any, registryAuth: any[] | undefined, component: string): Promise<any[]> {
377+
if (!Array.isArray(registryAuth) || registryAuth.length === 0) return registryAuth ?? [];
378+
const custody = getSecretCustody();
379+
const out: any[] = [];
380+
for (const entry of registryAuth) {
381+
// Already a reference (or nothing to seal without custody): leave as-is.
382+
if (entry.secret !== undefined || !custody) {
383+
out.push(entry);
384+
continue;
385+
}
386+
const name = deriveRegistrySecretName(component, entry.registry);
387+
// Reuse set_secret's seal-and-store path (encrypt with custody, grant to the component, audit
388+
// the mutation). The deploy request is already super_user, which set_secret requires.
389+
await setSecret({
390+
operation: terms.OPERATIONS_ENUM.SET_SECRET,
391+
hdb_user: req?.hdb_user,
392+
name,
393+
value: entry.token,
394+
grants: [component],
395+
processEnv: false,
396+
});
397+
out.push(entry.scope === undefined ? { registry: entry.registry, secret: name } : { registry: entry.registry, secret: name, scope: entry.scope });
398+
}
399+
return out;
400+
}
401+
402+
/** Wait (bounded) for a secret row to appear — covers the replicated-deploy race where a peer runs
403+
* the deploy before the origin's hdb_secret row has replicated in. */
404+
async function waitForSecretRow(table: any, name: string, waitMs: number): Promise<any> {
405+
const deadline = Date.now() + waitMs;
406+
let row = await table.get(name);
407+
while (!row && Date.now() < deadline) {
408+
await new Promise((resolve) => setTimeout(resolve, Math.min(200, Math.max(1, waitMs))));
409+
row = await table.get(name);
410+
}
411+
return row;
412+
}
413+
335414
/**
336415
* Resolve deploy_component `registryAuth` entries that reference a stored secret
337416
* (`{ registry, secret }`) into concrete token entries (`{ registry, token }`) by decrypting the
338417
* named hdb_secret row on this thread. Entries that already carry a literal `token` pass through
339-
* unchanged, so this is a no-op for the token-only form.
418+
* unchanged, so this is a no-op for the token-only fallback form.
340419
*
341420
* This is intentionally NOT a `get_secret` operation — the store never returns plaintext across the
342-
* API boundary. The resolved token is handed back to the deploy handler in-memory, fed to the
343-
* transient .npmrc, and stripped from the request before replication; it is never written back to
344-
* the request body, replicated, or logged.
421+
* API boundary. The resolved token is handed back to the install path in-memory, fed to the
422+
* transient .npmrc, and never written back to the request body, replicated, or logged.
345423
*
346424
* Authority mirrors the accessor model (#1550): the referenced secret must be usable by the
347425
* component being deployed — either a `processEnv` (global) secret or a scoped secret granted to
348426
* `component`. Without that, the deploy path would let a super_user pull an arbitrary scoped secret
349427
* into an .npmrc, sidestepping the grant that is the store's authority. A missing row, missing
350428
* grant, absent custody, or decrypt failure throws a ClientError naming the precise reason.
351429
*
430+
* `options.waitMs` gives a bounded grace period for a referenced row to replicate in (used on the
431+
* peer side of a replicated deploy, where the deploy op can arrive just ahead of the hdb_secret
432+
* row); it defaults to 0 (the origin wrote the row before it resolves, so no wait is needed).
433+
*
352434
* Runs on the main thread, where the operations API dispatches deploys and the Pro secrets
353435
* component registers custody — the same place set_secret decrypts. On a node without custody
354436
* (OSS core, or a custody key not held here) a referenced secret cannot be resolved and the deploy
355437
* fails loudly rather than silently installing without auth.
356438
*/
357439
export async function resolveRegistryAuth(
358440
registryAuth: any[] | undefined,
359-
component: string
441+
component: string,
442+
options: { waitMs?: number } = {}
360443
): Promise<ResolvedRegistryAuthEntry[] | undefined> {
361444
if (!Array.isArray(registryAuth) || registryAuth.length === 0) return registryAuth;
362445
// Token-only requests must not require custody or a provisioned store — keep the fast path pure.
363446
if (!registryAuth.some((entry) => entry && entry.secret !== undefined)) return registryAuth;
364447

448+
const waitMs = options.waitMs ?? 0;
365449
const table = secretTable();
366450
const resolved: ResolvedRegistryAuthEntry[] = [];
367451
for (const entry of registryAuth) {
@@ -370,7 +454,7 @@ export async function resolveRegistryAuth(
370454
continue;
371455
}
372456
const name: string = entry.secret;
373-
const row = await table.get(name);
457+
const row = waitMs > 0 ? await waitForSecretRow(table, name, waitMs) : await table.get(name);
374458
if (!row) {
375459
throw new ClientError(
376460
`registryAuth references secret '${name}', which does not exist`,

unitTests/components/Application.test.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const assert = require('node:assert');
55
const testUtils = require('../testUtils.js');
66
testUtils.preTestPrep();
77

8-
const { isSSHAuthFailure } = require('#src/components/Application');
8+
const { isSSHAuthFailure, assertApplicationConfig } = require('#src/components/Application');
99

1010
describe('isSSHAuthFailure', () => {
1111
it('returns true for "Could not read from remote repository"', () => {
@@ -65,3 +65,39 @@ npm error 404 '@scope/nonexistent-pkg@latest' is not in this registry.
6565
assert.strictEqual(isSSHAuthFailure(''), false);
6666
});
6767
});
68+
69+
describe('assertApplicationConfig registryAuth', () => {
70+
it('accepts a config with no registryAuth', () => {
71+
assert.doesNotThrow(() => assertApplicationConfig('app', { package: 'npm:@org/app@1.0.0' }));
72+
});
73+
74+
it('accepts registryAuth reference entries', () => {
75+
assert.doesNotThrow(() =>
76+
assertApplicationConfig('app', {
77+
package: 'npm:@org/app@1.0.0',
78+
registryAuth: [{ registry: 'https://npm.pkg.github.com', secret: 'deploy.app.gh', scope: '@org' }],
79+
})
80+
);
81+
});
82+
83+
it('rejects registryAuth that is not an array', () => {
84+
assert.throws(
85+
() => assertApplicationConfig('app', { package: 'p', registryAuth: { registry: 'r', secret: 's' } }),
86+
/expected array/
87+
);
88+
});
89+
90+
it('rejects a registryAuth entry carrying a literal token (references only on disk)', () => {
91+
assert.throws(
92+
() => assertApplicationConfig('app', { package: 'p', registryAuth: [{ registry: 'r', token: 'tok' }] }),
93+
/expected \{ registry, secret, scope\? \} reference/
94+
);
95+
});
96+
97+
it('rejects a registryAuth entry missing registry/secret', () => {
98+
assert.throws(
99+
() => assertApplicationConfig('app', { package: 'p', registryAuth: [{ secret: 's' }] }),
100+
/reference/
101+
);
102+
});
103+
});

0 commit comments

Comments
 (0)