diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx
new file mode 100644
index 000000000..5a55fe935
--- /dev/null
+++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * @vitest-environment jsdom
+ */
+/*
+ The delivery-tier flow on the shared secrets dialogs (enabled by `delivery`): picking
+ process.env vs scoped, seeing the matching access example, and reporting the choice through
+ onSet. Uses fireEvent (no @testing-library/user-event in this repo) and the Radix DOM polyfills.
+*/
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import { SecretRow, SecretsManager } from './SecretsManager';
+
+beforeAll(() => {
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+ if (typeof window.PointerEvent === 'undefined') {
+ window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent;
+ }
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+function renderManager(overrides: Partial
[0]> = {}) {
+ const onSet = vi.fn().mockResolvedValue(undefined);
+ const rows: SecretRow[] = overrides.rows ?? [];
+ render(
+ ,
+ );
+ return { onSet };
+}
+
+/** The rendered access example (the only on screen at a time). */
+function exampleText(): string {
+ return document.querySelector('pre code')?.textContent ?? '';
+}
+
+describe('SecretsManager delivery tier — Add', () => {
+ async function openAddWithKeyValue() {
+ fireEvent.click(screen.getByRole('button', { name: /add/i }));
+ fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } });
+ fireEvent.change(screen.getByLabelText('Value'), { target: { value: 'a value' } });
+ }
+
+ it('defaults to scoped and shows the secrets-accessor example', async () => {
+ renderManager();
+ await openAddWithKeyValue();
+
+ // Scoped is the default: the example destructures the accessor, never touches process.env.
+ expect(exampleText()).toContain("import { secrets } from 'harper';");
+ expect(exampleText()).toContain('const { NEW_KEY } = secrets;');
+ expect(exampleText()).not.toContain('process.env');
+ });
+
+ it('submits a scoped secret with its pending grants', async () => {
+ const { onSet } = renderManager();
+ await openAddWithKeyValue();
+
+ // Add one grant (the PendingGrantsInput "Add" button, distinct from "Add Secret").
+ fireEvent.change(screen.getByPlaceholderText('application name'), { target: { value: 'my-app' } });
+ fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
+ expect(screen.getByText('my-app')).toBeTruthy();
+
+ const submit = screen.getByRole('button', { name: /add secret/i });
+ await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(submit);
+
+ await waitFor(() => expect(onSet).toHaveBeenCalledTimes(1));
+ expect(onSet).toHaveBeenCalledWith('NEW_KEY', 'a value', { processEnv: false, grants: ['my-app'] });
+ });
+
+ it('switching to environment variable swaps the example and submits processEnv', async () => {
+ const { onSet } = renderManager();
+ await openAddWithKeyValue();
+
+ // [scoped, processEnv] in DOM order; select the second.
+ fireEvent.click(screen.getAllByRole('radio')[1]);
+ await waitFor(() => expect(exampleText()).toContain('process.env.NEW_KEY'));
+ expect(exampleText()).not.toContain('secrets');
+
+ const submit = screen.getByRole('button', { name: /add secret/i });
+ await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(submit);
+
+ await waitFor(() => expect(onSet).toHaveBeenCalledTimes(1));
+ expect(onSet).toHaveBeenCalledWith('NEW_KEY', 'a value', { processEnv: true, grants: undefined });
+ });
+});
+
+describe('SecretsManager delivery tier — Edit', () => {
+ it('preselects a processEnv row and re-saves it as processEnv', async () => {
+ const { onSet } = renderManager({
+ rows: [{ name: 'TOKEN', processEnv: true }],
+ selectedName: 'TOKEN',
+ });
+
+ // The edit dialog opens for the selected row; a process.env secret shows that example.
+ await waitFor(() => expect(exampleText()).toContain('process.env.TOKEN'));
+ expect(exampleText()).not.toContain('import { secrets }');
+
+ fireEvent.change(await screen.findByLabelText('New value'), { target: { value: 'rotated' } });
+ const save = screen.getByRole('button', { name: /^save$/i });
+ await waitFor(() => expect(save.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(save);
+
+ await waitFor(() => expect(onSet).toHaveBeenCalledTimes(1));
+ expect(onSet).toHaveBeenCalledWith('TOKEN', 'rotated', { processEnv: true });
+ });
+
+ it('a scoped row shows the accessor example', async () => {
+ renderManager({
+ rows: [{ name: 'DB_PASSWORD', processEnv: false }],
+ selectedName: 'DB_PASSWORD',
+ });
+ await waitFor(() => expect(exampleText()).toContain('const { DB_PASSWORD } = secrets;'));
+ });
+});
diff --git a/src/features/instance/secrets/SecretsManager.tsx b/src/features/instance/secrets/SecretsManager.tsx
index fd2c95ab6..11d464ad4 100644
--- a/src/features/instance/secrets/SecretsManager.tsx
+++ b/src/features/instance/secrets/SecretsManager.tsx
@@ -11,7 +11,8 @@ import { ENV_VALUE_MASK } from '@/lib/env/envFile';
import { ColumnDef, Row } from '@tanstack/react-table';
import { EyeIcon, EyeOffIcon, PlusIcon, RefreshCwIcon, TriangleAlertIcon } from 'lucide-react';
import { MouseEvent, ReactNode, useCallback, useMemo, useState } from 'react';
-import { AddSecretModal, EditSecretModal } from './SecretModals';
+import { SecretTier } from './accessExample';
+import { AddSecretModal, EditSecretModal, SecretDeliveryOptions } from './SecretModals';
export interface SecretRow {
name: string;
@@ -23,6 +24,12 @@ export interface SecretRow {
value?: string;
/** A per-row caution (e.g. "encrypted under a stale key"), shown as an alert icon. */
warning?: string;
+ /**
+ * The row's delivery tier, when the store supports one (the cluster hdb_secret store): true =
+ * materialized onto `process.env` (global), false/undefined = scoped to granted apps. Only read
+ * when `delivery` is enabled.
+ */
+ processEnv?: boolean;
}
export function SecretsManager({
@@ -40,6 +47,8 @@ export function SecretsManager({
onDelete,
renderEditExtras,
children,
+ delivery = false,
+ deliveryDefaultTier = 'scoped',
}: {
rows: SecretRow[];
isFetching?: boolean;
@@ -54,14 +63,21 @@ export function SecretsManager({
addDescription: ReactNode;
editDescription: ReactNode;
valueDescription?: ReactNode;
- /** Persist a key/value pair — both adding a new secret and replacing an existing value. */
- onSet: (key: string, value: string) => Promise;
+ /**
+ * Persist a key/value pair — both adding a new secret and replacing an existing value. The
+ * third argument carries delivery-tier choices when `delivery` is enabled (ignored otherwise).
+ */
+ onSet: (key: string, value: string, options?: SecretDeliveryOptions) => Promise;
/** Remove a secret by key. Omit to hide the delete affordance. */
onDelete?: (key: string) => Promise;
- /** Extra per-secret content for the edit dialog (e.g. a grants editor). */
+ /** Extra per-secret content for the edit dialog (e.g. a live grants editor). */
renderEditExtras?: (name: string) => ReactNode;
/** Extra toolbar actions, rendered after Refresh/Add. */
children?: ReactNode;
+ /** Enable the delivery-tier chooser (process.env vs scoped) + access examples in the dialogs. */
+ delivery?: boolean;
+ /** Tier pre-selected in the Add dialog (defaults to the safer scoped tier). */
+ deliveryDefaultTier?: SecretTier;
}) {
const columns = useMemo>>(() => [
{
@@ -131,7 +147,9 @@ export function SecretsManager({
description={addDescription}
valueDescription={valueDescription}
existingKeys={existingKeys}
- onSubmit={({ key, value }) => onSet(key, value)}
+ delivery={delivery}
+ defaultTier={deliveryDefaultTier}
+ onSubmit={({ key, value, ...options }) => onSet(key, value, options)}
/>
)}
{selectedName && selectedRow && (
@@ -141,7 +159,9 @@ export function SecretsManager({
description={editDescription}
valueDescription={valueDescription}
currentValue={selectedRow.value}
- onSave={(value) => onSet(selectedRow.name, value)}
+ delivery={delivery}
+ currentTier={selectedRow.processEnv ? 'processEnv' : 'scoped'}
+ onSave={(value, options) => onSet(selectedRow.name, value, options)}
onDelete={onDelete && (() => onDelete(selectedRow.name))}
closeModal={() => onSelectName(undefined)}
>
diff --git a/src/features/instance/secrets/accessExample.test.ts b/src/features/instance/secrets/accessExample.test.ts
new file mode 100644
index 000000000..e29ed726c
--- /dev/null
+++ b/src/features/instance/secrets/accessExample.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { buildSecretAccessExample, isJsIdentifier, SECRET_NAME_PLACEHOLDER } from './accessExample';
+
+describe('isJsIdentifier', () => {
+ it('accepts bare identifiers', () => {
+ expect(isJsIdentifier('MY_KEY')).toBe(true);
+ expect(isJsIdentifier('_private')).toBe(true);
+ expect(isJsIdentifier('$ref')).toBe(true);
+ expect(isJsIdentifier('token2')).toBe(true);
+ });
+
+ it('rejects names that need bracket access', () => {
+ expect(isJsIdentifier('my.key')).toBe(false); // dot
+ expect(isJsIdentifier('my-key')).toBe(false); // dash
+ expect(isJsIdentifier('2fa')).toBe(false); // leading digit
+ expect(isJsIdentifier('')).toBe(false);
+ });
+});
+
+describe('buildSecretAccessExample', () => {
+ it('processEnv uses process.env dot access for identifier names', () => {
+ const code = buildSecretAccessExample('API_KEY', 'processEnv');
+ expect(code).toContain('const value = process.env.API_KEY;');
+ expect(code).toContain('process.env for every component');
+ expect(code).not.toContain('import { secrets }');
+ });
+
+ it('processEnv uses bracket access for non-identifier names', () => {
+ expect(buildSecretAccessExample('my.key', 'processEnv')).toContain("const value = process.env['my.key'];");
+ expect(buildSecretAccessExample('my-key', 'processEnv')).toContain("const value = process.env['my-key'];");
+ });
+
+ it('scoped destructures the secrets accessor for identifier names', () => {
+ const code = buildSecretAccessExample('DB_PASSWORD', 'scoped');
+ expect(code).toContain("import { secrets } from 'harper';");
+ expect(code).toContain('const { DB_PASSWORD } = secrets;');
+ expect(code).toContain('top level'); // the module-top-level guidance
+ expect(code).not.toContain('process.env');
+ });
+
+ it('scoped uses bracket access for non-identifier names', () => {
+ const code = buildSecretAccessExample('my.key', 'scoped');
+ expect(code).toContain("const value = secrets['my.key'];");
+ expect(code).not.toContain('const { my.key }');
+ });
+
+ it('falls back to a placeholder name when empty or whitespace', () => {
+ expect(buildSecretAccessExample('', 'scoped')).toContain(`const { ${SECRET_NAME_PLACEHOLDER} } = secrets;`);
+ expect(buildSecretAccessExample(' ', 'processEnv')).toContain(`process.env.${SECRET_NAME_PLACEHOLDER}`);
+ });
+
+ it('escapes single quotes in bracketed names', () => {
+ expect(buildSecretAccessExample("a'b", 'processEnv')).toContain("process.env['a\\'b']");
+ });
+});
diff --git a/src/features/instance/secrets/accessExample.ts b/src/features/instance/secrets/accessExample.ts
new file mode 100644
index 000000000..a278b07e1
--- /dev/null
+++ b/src/features/instance/secrets/accessExample.ts
@@ -0,0 +1,61 @@
+/**
+ * Inline "how do I read this secret?" code examples for the two hdb_secret delivery tiers
+ * (harper#1554 / harper#1559). The tier a secret is stored under decides how component code reads
+ * it, so the UI shows the matching snippet as soon as the tier is picked:
+ *
+ * - 'processEnv' — the value is materialized into the real `process.env` at component load and
+ * inherited by child processes (global, `.env` semantics). Read it as `process.env.NAME`.
+ * - 'scoped' — never placed on `process.env`; exposed only to granted components through the
+ * `secrets` accessor (`import { secrets } from 'harper'`), read at module top level.
+ *
+ * The two tiers are mutually exclusive server-side (a processEnv secret is global, so scoping it
+ * with grants is rejected). The secret-name grammar (`[\w.-]+`) permits `.` and `-`, which aren't
+ * valid JS identifiers, so the examples switch between dot/destructure and bracket notation per
+ * name to stay copy-paste-correct.
+ */
+
+export type SecretTier = 'processEnv' | 'scoped';
+
+/** Shown in the example while the user hasn't typed a name yet. */
+export const SECRET_NAME_PLACEHOLDER = 'MY_SECRET';
+
+/** True when `name` can be used as a bare JS identifier (so `secrets.NAME` / `const { NAME }` is valid). */
+export function isJsIdentifier(name: string): boolean {
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
+}
+
+/** A single-quoted JS string literal for a name that can't be a bare identifier. */
+function quote(name: string): string {
+ return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+}
+
+/**
+ * The code a component uses to read a secret of the given tier. `name` is the stored secret name;
+ * an empty/whitespace name falls back to {@link SECRET_NAME_PLACEHOLDER} so the example still reads
+ * sensibly while the user is typing.
+ */
+export function buildSecretAccessExample(name: string, tier: SecretTier): string {
+ const key = name.trim() || SECRET_NAME_PLACEHOLDER;
+ const identifier = isJsIdentifier(key);
+
+ if (tier === 'processEnv') {
+ const access = identifier ? `process.env.${key}` : `process.env[${quote(key)}]`;
+ return [
+ '// Exposed on process.env for every component (and child processes).',
+ `const value = ${access};`,
+ ].join('\n');
+ }
+
+ // Scoped: the `secrets` accessor is bound to the loading component, so it must be read at module
+ // top level (during load) — reading it inside a request handler throws.
+ const read = identifier
+ ? `const { ${key} } = secrets;`
+ : `const value = secrets[${quote(key)}];`;
+ return [
+ "import { secrets } from 'harper';",
+ '',
+ '// Read granted secrets at the top level of your component module',
+ '// (the accessor is bound during load, not inside request handlers).',
+ read,
+ ].join('\n');
+}
diff --git a/src/integrations/api/instance/secrets/secrets.test.tsx b/src/integrations/api/instance/secrets/secrets.test.tsx
new file mode 100644
index 000000000..b1ee25b02
--- /dev/null
+++ b/src/integrations/api/instance/secrets/secrets.test.tsx
@@ -0,0 +1,203 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { EntityIds } from '@/features/auth/store/authStore';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react';
+import type { AxiosInstance } from 'axios';
+import { webcrypto } from 'node:crypto';
+import { PropsWithChildren } from 'react';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import { secretsPublicKeyQueryOptions, useDeleteSecret, useSetSecret } from './secrets';
+
+/*
+ Exercises the hdb_secret client flow against a mocked operations client and a REAL RSA keypair:
+ set_secret must carry an `enc:v1:` envelope whose sealed kid matches the served fingerprint (the
+ plaintext never appears in the request), and a kid-mismatch rejection (custody key rotated under
+ our cached copy) must refetch the key and re-encrypt exactly once.
+ */
+
+// Managed clusters fetch the public key from central-manager's apiClient; keep the real module
+// (and its env expectations) out of the test.
+const { cmPost } = vi.hoisted(() => ({ cmPost: vi.fn() }));
+vi.mock('@/config/apiClient', () => ({ apiClient: { post: cmPost } }));
+
+// jsdom has no SubtleCrypto; Node's WebCrypto implements the same interface.
+beforeAll(() => {
+ if (!globalThis.crypto?.subtle) {
+ Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true });
+ }
+});
+
+afterEach(() => vi.clearAllMocks());
+
+async function generateKey() {
+ const keyPair = await webcrypto.subtle.generateKey(
+ { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
+ true,
+ ['encrypt', 'decrypt'],
+ );
+ const der = new Uint8Array(await webcrypto.subtle.exportKey('spki', keyPair.publicKey));
+ const b64 = btoa(String.fromCharCode(...der));
+ const pem = `-----BEGIN PUBLIC KEY-----\n${b64.replace(/(.{64})/g, '$1\n')}\n-----END PUBLIC KEY-----\n`;
+ const fingerprintBytes = new Uint8Array(await webcrypto.subtle.digest('SHA-256', der));
+ const fingerprint = Array.from(fingerprintBytes).map((b) => b.toString(16).padStart(2, '0')).join('');
+ return { pem, fingerprint };
+}
+
+function decodeEnvelope(envelope: string): { kid: string } {
+ expect(envelope.startsWith('enc:v1:')).toBe(true);
+ const b64 = envelope.slice('enc:v1:'.length).replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(b64));
+}
+
+function harness() {
+ const post = vi.fn();
+ const instanceClient = { post } as unknown as AxiosInstance;
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+ const params = { instanceClient, entityId: 'test-cluster' as EntityIds };
+ return { post, queryClient, wrapper, params };
+}
+
+describe('useSetSecret', () => {
+ it('encrypts client-side and posts an envelope sealed with the served key', async () => {
+ const { post, wrapper, params } = harness();
+ const key = await generateKey();
+ post.mockImplementation((_url: string, body: { operation: string }) => {
+ if (body.operation === 'get_secrets_public_key') {
+ return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } });
+ }
+ if (body.operation === 'set_secret') {
+ return Promise.resolve({ data: { name: 'API_KEY', kid: key.fingerprint, created: true } });
+ }
+ return Promise.reject(new Error(`Unexpected operation ${body.operation}`));
+ });
+
+ const { result } = renderHook(() => useSetSecret(), { wrapper });
+ const response = await result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'hunter2' });
+
+ expect(response.created).toBe(true);
+ const setCalls = post.mock.calls.filter(([, body]) => body.operation === 'set_secret');
+ expect(setCalls).toHaveLength(1);
+ const [, setBody] = setCalls[0];
+ expect(setBody.name).toBe('API_KEY');
+ expect(setBody.value).toBeUndefined(); // plaintext never leaves the browser
+ expect(JSON.stringify(setBody)).not.toContain('hunter2');
+ expect(decodeEnvelope(setBody.envelope).kid).toBe(key.fingerprint);
+ });
+
+ it('refetches the public key and re-encrypts once on a kid mismatch (key rotation)', async () => {
+ const { post, wrapper, params } = harness();
+ const staleKey = await generateKey();
+ const activeKey = await generateKey();
+ let servedKey = staleKey;
+ post.mockImplementation((_url: string, body: { operation: string; envelope?: string }) => {
+ if (body.operation === 'get_secrets_public_key') {
+ return Promise.resolve({ data: { public_key: servedKey.pem, fingerprint: servedKey.fingerprint } });
+ }
+ if (body.operation === 'set_secret') {
+ const { kid } = decodeEnvelope(body.envelope!);
+ if (kid !== activeKey.fingerprint) {
+ // The moment the stale envelope is rejected, the node serves the rotated key.
+ servedKey = activeKey;
+ return Promise.reject({
+ response: {
+ data: { error: `Secret envelope kid '${kid}' does not match this cluster's secrets key` },
+ },
+ });
+ }
+ return Promise.resolve({ data: { name: 'API_KEY', kid, created: false } });
+ }
+ return Promise.reject(new Error(`Unexpected operation ${body.operation}`));
+ });
+
+ const { result } = renderHook(() => useSetSecret(), { wrapper });
+ const response = await result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'rotated' });
+
+ expect(response.kid).toBe(activeKey.fingerprint);
+ const setCalls = post.mock.calls.filter(([, body]) => body.operation === 'set_secret');
+ expect(setCalls).toHaveLength(2); // stale attempt + one re-encrypted retry
+ expect(decodeEnvelope(setCalls[1][1].envelope).kid).toBe(activeKey.fingerprint);
+ });
+
+ it('surfaces the operation error message (not Axios noise) when the retry also fails', async () => {
+ const { post, wrapper, params } = harness();
+ const key = await generateKey();
+ post.mockImplementation((_url: string, body: { operation: string }) => {
+ if (body.operation === 'get_secrets_public_key') {
+ return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } });
+ }
+ return Promise.reject({ response: { data: { error: 'secrets custody is not initialized on this node' } } });
+ });
+
+ const { result } = renderHook(() => useSetSecret(), { wrapper });
+ await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toThrow(
+ 'secrets custody is not initialized on this node',
+ );
+ });
+
+ it('fetches the key from central-manager for managed clusters (central-manager#409)', async () => {
+ const { post, wrapper, params } = harness();
+ const key = await generateKey();
+ cmPost.mockResolvedValue({
+ data: { publicKey: key.pem, fingerprint: key.fingerprint, scheme: 'enc:v1', algorithm: 'RSA-OAEP-256' },
+ });
+ post.mockImplementation((_url: string, body: { operation: string }) =>
+ body.operation === 'set_secret'
+ ? Promise.resolve({ data: { name: 'API_KEY', kid: key.fingerprint, created: true } })
+ : Promise.reject(new Error(`Unexpected operation ${body.operation}`))
+ );
+
+ const { result } = renderHook(() => useSetSecret(), { wrapper });
+ await result.current.mutateAsync({ ...params, managedClusterId: 'clu-123', name: 'API_KEY', value: 'v' });
+
+ expect(cmPost).toHaveBeenCalledWith('/ClusterSecrets', {
+ operation: 'get_secrets_public_key',
+ clusterId: 'clu-123',
+ });
+ // The node op is never used for the key; the envelope targets CM's key; the write still
+ // goes to the cluster itself.
+ expect(post.mock.calls.filter(([, body]) => body.operation === 'get_secrets_public_key')).toHaveLength(0);
+ const setCall = post.mock.calls.find(([, body]) => body.operation === 'set_secret');
+ expect(decodeEnvelope(setCall![1].envelope).kid).toBe(key.fingerprint);
+ });
+
+ it('caches the public key between writes', async () => {
+ const { post, wrapper, params, queryClient } = harness();
+ const key = await generateKey();
+ post.mockImplementation((_url: string, body: { operation: string }) => {
+ if (body.operation === 'get_secrets_public_key') {
+ return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } });
+ }
+ return Promise.resolve({ data: { name: 'X', kid: key.fingerprint, created: true } });
+ });
+
+ const { result } = renderHook(() => useSetSecret(), { wrapper });
+ await result.current.mutateAsync({ ...params, name: 'A', value: '1' });
+ await result.current.mutateAsync({ ...params, name: 'B', value: '2' });
+
+ const keyFetches = post.mock.calls.filter(([, body]) => body.operation === 'get_secrets_public_key');
+ expect(keyFetches).toHaveLength(1);
+ // And the cached entry is the one ensureQueryData used.
+ await waitFor(() => expect(queryClient.getQueryData(secretsPublicKeyQueryOptions(params).queryKey)).toBeTruthy());
+ });
+});
+
+describe('useDeleteSecret', () => {
+ it('posts delete_secret and surfaces the body error message on failure', async () => {
+ const { post, wrapper, params } = harness();
+ post.mockResolvedValueOnce({ data: { message: "Successfully deleted secret 'API_KEY'" } });
+
+ const { result } = renderHook(() => useDeleteSecret(), { wrapper });
+ await result.current.mutateAsync({ ...params, name: 'API_KEY' });
+ expect(post).toHaveBeenCalledWith('/', { operation: 'delete_secret', name: 'API_KEY' });
+
+ post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } });
+ await expect(result.current.mutateAsync({ ...params, name: 'NOPE' })).rejects.toThrow(
+ "No secret found with name 'NOPE'",
+ );
+ });
+});
diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts
new file mode 100644
index 000000000..ac6807a75
--- /dev/null
+++ b/src/integrations/api/instance/secrets/secrets.ts
@@ -0,0 +1,219 @@
+/**
+ * Cluster secrets — the replicated `system.hdb_secret` store (harper#1554; key custody in
+ * harper-pro#512, tracked by harper-pro#166).
+ *
+ * Secrets are named, envelope-encrypted rows on the cluster itself, managed through the ordinary
+ * per-instance operations API. Values are encrypted **in the browser** (`enc:v1` envelope against
+ * the cluster secrets public key) before they leave, so plaintext never reaches the operations
+ * API, the operation log, or disk — and can never be read back. Rows replicate to every node via
+ * normal system-table replication; at load time core materializes a secret only into components
+ * listed in its `grants`.
+ *
+ * The public key has two sources depending on who holds custody (normalized here to one shape):
+ * Fabric-managed clusters fetch it from central-manager (`POST /ClusterSecrets`,
+ * central-manager#409 — CM mints the per-cluster keypair on first use and delivers the private
+ * key to hosts), while self-hosted/local nodes serve their own file-tier key via the
+ * `get_secrets_public_key` operation. Without custody the node has no key — the fetch fails with
+ * a clean error and nothing can be encrypted; `list_secrets` still works, so the store is
+ * browsable read-only in that state.
+ */
+import { apiClient } from '@/config/apiClient';
+import { InstanceClientIdConfig } from '@/config/instanceClientConfig';
+import { encryptEnvelope } from '@/lib/crypto/envSecret';
+import { QueryClient, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
+import type { AxiosInstance } from 'axios';
+
+/** The cluster key clients encrypt against, normalized across the two sources (see below). */
+export interface SecretsPublicKey {
+ publicKey: string;
+ fingerprint: string;
+}
+
+/** One `list_secrets` row: metadata only — never envelopes, never values. */
+export interface SecretMetadata {
+ name: string;
+ /** Fingerprint of the key the stored envelope was encrypted under (null for kid-less rows). */
+ kid: string | null;
+ /** Component names allowed to receive this secret at load time. */
+ grants: string[];
+ /** Materialize into every component's process.env instead of per-component grants (mutually exclusive with grants). */
+ processEnv?: boolean;
+ metadata: Record;
+ /** Stored without key-identity verification (kid-less envelope, or no custody at write time). */
+ unverified: boolean;
+ updated_by: string | null;
+ __createdtime__: number;
+ __updatedtime__: number;
+ /** False when the row was encrypted under a different key than the node's current one. */
+ kid_matches_custody: boolean;
+}
+
+export interface ListSecretsResponse {
+ secrets: SecretMetadata[];
+ /** The node's current custody key fingerprint; null when no custody is registered. */
+ custody_fingerprint: string | null;
+}
+
+/** Operations API errors carry the message in the response body; surface that, not the Axios noise. */
+function operationErrorMessage(error: unknown): string {
+ const body = (error as { response?: { data?: { error?: string; message?: string } } })?.response?.data;
+ return body?.error ?? body?.message ?? String(error);
+}
+
+async function getSecretsPublicKeyFromNode({ instanceClient }: InstanceClientIdConfig): Promise {
+ const { data } = await instanceClient.post<{ public_key: string; fingerprint: string }>('/', {
+ operation: 'get_secrets_public_key',
+ });
+ return { publicKey: data.public_key, fingerprint: data.fingerprint };
+}
+
+// `apiClient` is a TypedAxios bound to the generated OpenAPI spec; `/ClusterSecrets` isn't in the
+// spec yet, so use the plain Axios view. (Regenerate the SDK to type this once the endpoint ships.)
+const cm = apiClient as unknown as AxiosInstance;
+
+async function getSecretsPublicKeyFromClusterManager(clusterId: string): Promise {
+ const { data } = await cm.post<{ publicKey: string; fingerprint: string }>('/ClusterSecrets', {
+ operation: 'get_secrets_public_key',
+ clusterId,
+ });
+ return { publicKey: data.publicKey, fingerprint: data.fingerprint };
+}
+
+export interface SecretsPublicKeySource extends InstanceClientIdConfig {
+ /**
+ * For Fabric-managed clusters, the clusterId to fetch the key from central-manager
+ * (central-manager#409): CM is the custodian there — it mints the keypair on first use and
+ * delivers the private key to hosts, so the key exists (and is authoritative) even before any
+ * node has custody registered. Omit for self-hosted/local, where the node mints its own
+ * file-tier key and serves it via the `get_secrets_public_key` operation.
+ */
+ managedClusterId?: string;
+}
+
+export function secretsPublicKeyQueryOptions(params: SecretsPublicKeySource) {
+ return queryOptions({
+ queryKey: [params.entityId, 'get_secrets_public_key', params.managedClusterId ?? 'node'] as const,
+ queryFn: () =>
+ params.managedClusterId
+ ? getSecretsPublicKeyFromClusterManager(params.managedClusterId)
+ : getSecretsPublicKeyFromNode(params),
+ // The custody key can rotate (kid map), so refresh periodically instead of caching forever.
+ // Failure is a state, not a blip: without custody the node simply has no key.
+ staleTime: 5 * 60_000,
+ retry: false,
+ });
+}
+
+async function listSecrets({ instanceClient }: InstanceClientIdConfig): Promise {
+ const { data } = await instanceClient.post('/', { operation: 'list_secrets' });
+ return data;
+}
+
+export function listSecretsQueryOptions(params: InstanceClientIdConfig) {
+ return queryOptions({
+ queryKey: [params.entityId, 'list_secrets'] as const,
+ queryFn: () => listSecrets(params),
+ });
+}
+
+export interface SetSecretArgs extends SecretsPublicKeySource {
+ name: string;
+ value: string;
+ /**
+ * Delivery tier. true → materialized onto `process.env` for every component (global); false →
+ * scoped, exposed only to granted components via the `secrets` accessor. Omit to preserve the
+ * stored tier (the backend defaults a brand-new secret to scoped). processEnv and grants are
+ * mutually exclusive — a processEnv secret is global, so it rejects grants.
+ */
+ processEnv?: boolean;
+ /** Initial grants for a scoped secret (the add flow; edit manages grants via grant/revoke). */
+ grants?: string[];
+}
+
+async function encryptAndSetSecret(queryClient: QueryClient, args: SetSecretArgs, isRetry: boolean): Promise<{
+ name: string;
+ kid: string | null;
+ created: boolean;
+}> {
+ // Encrypt against the cluster public key so the plaintext value never leaves the browser. The
+ // envelope's sealed kid tells the cluster which key it was encrypted under.
+ const { publicKey, fingerprint } = await queryClient.ensureQueryData(secretsPublicKeyQueryOptions(args));
+ const envelope = await encryptEnvelope(args.value, publicKey, fingerprint);
+ try {
+ const { data } = await args.instanceClient.post('/', {
+ operation: 'set_secret',
+ name: args.name,
+ envelope,
+ // Only send the tier fields when the caller set them, so a plain value rotation preserves
+ // the stored tier (the backend falls back to the existing row otherwise).
+ ...(args.processEnv !== undefined ? { processEnv: args.processEnv } : {}),
+ ...(args.grants !== undefined ? { grants: args.grants } : {}),
+ });
+ return data;
+ } catch (error) {
+ const message = operationErrorMessage(error);
+ // A kid mismatch means our cached public key is stale (the custody key rotated). Drop the
+ // cached key (ensureQueryData returns stale entries, so invalidation isn't enough) and
+ // re-encrypt once; anything else (or a second mismatch) surfaces to the caller.
+ if (!isRetry && message.includes('does not match')) {
+ queryClient.removeQueries({ queryKey: secretsPublicKeyQueryOptions(args).queryKey });
+ return encryptAndSetSecret(queryClient, args, true);
+ }
+ throw new Error(message);
+ }
+}
+
+export function useSetSecret() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (args: SetSecretArgs) => encryptAndSetSecret(queryClient, args, false),
+ });
+}
+
+export interface DeleteSecretArgs extends InstanceClientIdConfig {
+ name: string;
+}
+
+async function deleteSecret({ instanceClient, name }: DeleteSecretArgs): Promise<{ message: string }> {
+ try {
+ const { data } = await instanceClient.post('/', { operation: 'delete_secret', name });
+ return data;
+ } catch (error) {
+ throw new Error(operationErrorMessage(error));
+ }
+}
+
+export function useDeleteSecret() {
+ return useMutation({ mutationFn: deleteSecret });
+}
+
+export interface GrantSecretArgs extends InstanceClientIdConfig {
+ name: string;
+ component: string;
+}
+
+export interface GrantSecretResponse {
+ name: string;
+ grants: string[];
+ changed: boolean;
+}
+
+async function mutateGrant(
+ operation: 'grant_secret' | 'revoke_secret',
+ { instanceClient, name, component }: GrantSecretArgs,
+): Promise {
+ try {
+ const { data } = await instanceClient.post('/', { operation, name, component });
+ return data;
+ } catch (error) {
+ throw new Error(operationErrorMessage(error));
+ }
+}
+
+export function useGrantSecret() {
+ return useMutation({ mutationFn: (args: GrantSecretArgs) => mutateGrant('grant_secret', args) });
+}
+
+export function useRevokeSecret() {
+ return useMutation({ mutationFn: (args: GrantSecretArgs) => mutateGrant('revoke_secret', args) });
+}
diff --git a/src/lib/crypto/envSecret.test.ts b/src/lib/crypto/envSecret.test.ts
new file mode 100644
index 000000000..6900c1ee2
--- /dev/null
+++ b/src/lib/crypto/envSecret.test.ts
@@ -0,0 +1,72 @@
+// @vitest-environment node
+// Runs in the node env so Web Crypto (globalThis.crypto.subtle, used by envSecret.ts) and node:crypto
+// (used here to stand in for the backend decrypt) are both available.
+import {
+ constants,
+ createDecipheriv,
+ createHash,
+ createPublicKey,
+ generateKeyPairSync,
+ privateDecrypt,
+} from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import { encryptEnvelope, ENV_ENCRYPTED_PREFIX, fingerprintOf, isEncryptedEnvValue } from './envSecret';
+
+// Mirrors the backend enc:v1 decrypt (harper-pro envSecretCrypto / central-manager deploymentSecrets):
+// RSA-OAEP(SHA-256) unwraps the AES key, AES-256-GCM decrypts the value.
+function backendDecrypt(value: string, privateKeyPem: string): string {
+ const env = JSON.parse(Buffer.from(value.slice(ENV_ENCRYPTED_PREFIX.length), 'base64url').toString('utf8'));
+ const aesKey = privateDecrypt(
+ { key: privateKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
+ Buffer.from(env.k, 'base64'),
+ );
+ const decipher = createDecipheriv('aes-256-gcm', aesKey, Buffer.from(env.iv, 'base64'));
+ decipher.setAuthTag(Buffer.from(env.tag, 'base64'));
+ return Buffer.concat([decipher.update(Buffer.from(env.ct, 'base64')), decipher.final()]).toString('utf8');
+}
+
+// 2048-bit key keeps the test fast; the envelope code paths are identical at any RSA size.
+const { publicKey, privateKey } = generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+const publicKeyPem = publicKey as unknown as string;
+
+describe('envSecret (browser enc:v1)', () => {
+ it('fingerprintOf matches the backend (sha256 of DER SPKI)', async () => {
+ const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
+ const expected = createHash('sha256').update(der).digest('hex');
+ expect(await fingerprintOf(publicKeyPem)).toBe(expected);
+ });
+
+ it('encrypts values the backend can decrypt (round-trip interop)', async () => {
+ const kid = await fingerprintOf(publicKeyPem);
+ const samples = [
+ 'sk-1234567890',
+ 'p@ss w#rd"with\'quotes',
+ 'multi\nline\nvalue',
+ 'unicode: café 🔐 日本語',
+ '-----BEGIN PRIVATE KEY-----\n' + 'A'.repeat(1500) + '\n-----END PRIVATE KEY-----\n',
+ '',
+ ];
+ for (const value of samples) {
+ const envelope = await encryptEnvelope(value, publicKeyPem, kid);
+ expect(isEncryptedEnvValue(envelope)).toBe(true);
+ expect(backendDecrypt(envelope, privateKey as unknown as string)).toBe(value);
+ }
+ });
+
+ it('produces a fresh iv/key each call (no nonce reuse)', async () => {
+ const kid = await fingerprintOf(publicKeyPem);
+ const a = await encryptEnvelope('same', publicKeyPem, kid);
+ const b = await encryptEnvelope('same', publicKeyPem, kid);
+ expect(a).not.toBe(b);
+ });
+
+ it('isEncryptedEnvValue only matches the enc:v1 prefix', () => {
+ expect(isEncryptedEnvValue('plain')).toBe(false);
+ expect(isEncryptedEnvValue('enc:v2:x')).toBe(false);
+ expect(isEncryptedEnvValue(undefined)).toBe(false);
+ });
+});
diff --git a/src/lib/crypto/envSecret.ts b/src/lib/crypto/envSecret.ts
new file mode 100644
index 000000000..77b4ca0ef
--- /dev/null
+++ b/src/lib/crypto/envSecret.ts
@@ -0,0 +1,107 @@
+/**
+ * Client-side `enc:v1:` envelope encryption using the Web Crypto API, matching the backend contract
+ * in core/docs/env-secret-encryption.md. Hybrid: AES-256-GCM encrypts the value, RSA-OAEP(SHA-256)
+ * wraps the AES key. Secret values are encrypted in the browser and never leave it in plaintext.
+ *
+ * enc:v1:
+ */
+
+export const ENV_ENCRYPTED_PREFIX = 'enc:v1:';
+
+function pemToDer(pem: string): Uint8Array {
+ const b64 = pem
+ .replace(/-----BEGIN [^-]+-----/, '')
+ .replace(/-----END [^-]+-----/, '')
+ .replace(/\s+/g, '');
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
+ return bytes;
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let s = '';
+ for (let i = 0; i < bytes.length; i++) { s += String.fromCharCode(bytes[i]); }
+ return btoa(s);
+}
+
+function toBase64Url(bytes: Uint8Array): string {
+ return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function toHex(buffer: ArrayBuffer): string {
+ return Array.from(new Uint8Array(buffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+// Web Crypto's BufferSource typing (TS 5.7+) rejects Uint8Array; hand it a plain
+// ArrayBuffer copy of the bytes.
+function ab(bytes: Uint8Array): ArrayBuffer {
+ // Avoid a copy when the view already spans its whole buffer (the common case here).
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
+ return bytes.buffer as ArrayBuffer;
+ }
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+}
+
+// Web Crypto's SubtleCrypto is only available in secure contexts (HTTPS or localhost). Fail with a
+// clear message rather than a cryptic `undefined` access when the app is served over plain HTTP.
+function requireSubtle(): SubtleCrypto {
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
+ throw new Error('Secret encryption requires a secure context (HTTPS or localhost).');
+ }
+ return crypto.subtle;
+}
+
+/** True if a value is an `enc:v1:` envelope rather than a plaintext value. */
+export function isEncryptedEnvValue(value: unknown): value is string {
+ return typeof value === 'string' && value.startsWith(ENV_ENCRYPTED_PREFIX);
+}
+
+/**
+ * SHA-256 (hex) of the DER SPKI public key — the envelope `kid`. Matches the backend `fingerprintOf`
+ * so a value encrypted here targets the right key during rotation.
+ */
+export async function fingerprintOf(publicKeyPem: string): Promise {
+ const subtle = requireSubtle();
+ const der = pemToDer(publicKeyPem);
+ return toHex(await subtle.digest('SHA-256', ab(der)));
+}
+
+/**
+ * Encrypt a value into an `enc:v1:` envelope for the given public key. `kid` should be
+ * `fingerprintOf(publicKeyPem)` (the server returns it alongside the key).
+ */
+export async function encryptEnvelope(plaintext: string, publicKeyPem: string, kid: string): Promise {
+ const subtle = requireSubtle();
+ const der = pemToDer(publicKeyPem);
+ const rsaKey = await subtle.importKey('spki', ab(der), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [
+ 'encrypt',
+ ]);
+
+ const aesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const aesKey = await subtle.importKey('raw', ab(aesKeyBytes), { name: 'AES-GCM' }, false, ['encrypt']);
+
+ const sealed = new Uint8Array(
+ await subtle.encrypt(
+ { name: 'AES-GCM', iv: ab(iv), tagLength: 128 },
+ aesKey,
+ ab(new TextEncoder().encode(plaintext)),
+ ),
+ );
+ // Web Crypto appends the 16-byte GCM tag to the ciphertext; the backend expects them separate.
+ const ct = sealed.slice(0, sealed.length - 16);
+ const tag = sealed.slice(sealed.length - 16);
+ const wrappedKey = new Uint8Array(await subtle.encrypt({ name: 'RSA-OAEP' }, rsaKey, ab(aesKeyBytes)));
+
+ const envelope = {
+ kid,
+ k: toBase64(wrappedKey),
+ iv: toBase64(iv),
+ ct: toBase64(ct),
+ tag: toBase64(tag),
+ };
+ return ENV_ENCRYPTED_PREFIX + toBase64Url(new TextEncoder().encode(JSON.stringify(envelope)));
+}
diff --git a/src/lib/string/wasAReleasedBeforeB.test.ts b/src/lib/string/wasAReleasedBeforeB.test.ts
index 1f5fb01a0..f854824ac 100644
--- a/src/lib/string/wasAReleasedBeforeB.test.ts
+++ b/src/lib/string/wasAReleasedBeforeB.test.ts
@@ -43,6 +43,23 @@ describe('wasAReleasedBeforeB (b >= a)', () => {
expect(wasAReleasedBeforeB('3.4.5', '3.4.5')).toBe(true);
expect(wasAReleasedBeforeB('3.4.5-beta.2', '3.4.5-beta.2')).toBe(true);
});
+
+ // The Secrets nav gate (src/features/instance/config/index.tsx) floors at '5.2.0-alpha.1' rather
+ // than '5.2.0' so the harper#1554 alpha/beta dev builds pass it too. Guard that intent: a plain
+ // '5.2.0' floor would header-exclude every prerelease, hiding the page on the very builds it ships in.
+ it('5.2.0-alpha.1 secrets gate admits 5.2 prereleases/rc/final but not pre-5.2 builds', () => {
+ const gate = '5.2.0-alpha.1';
+ expect(wasAReleasedBeforeB(gate, '5.1.15')).toBe(false); // current stage line — hidden
+ expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.1')).toBe(true); // earliest 5.2 prerelease — shown
+ expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.2')).toBe(true);
+ expect(wasAReleasedBeforeB(gate, '5.2.0-beta.1')).toBe(true); // the shipping beta — shown
+ expect(wasAReleasedBeforeB(gate, '5.2.0-rc.1')).toBe(true);
+ expect(wasAReleasedBeforeB(gate, '5.2.0')).toBe(true); // final release — shown
+ expect(wasAReleasedBeforeB(gate, '5.2.1')).toBe(true);
+ expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.1+abcdef')).toBe(true); // build metadata ignored
+ // A plain '5.2.0' floor would wrongly hide the shipping prereleases — the bug this gate avoids:
+ expect(wasAReleasedBeforeB('5.2.0', '5.2.0-alpha.1')).toBe(false);
+ });
});
import { compareVersions } from './wasAReleasedBeforeB';