From b9c1666f02d5b806d85e4b95c9e7bc0efc9f2ab4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:29:14 -0400 Subject: [PATCH 1/3] feat(secrets): teach secrets.subscribe() and link docs in config guidance The inline 'How to read it in your component' guidance on the cluster Secrets config page now shows the live-change API from harper#1787: - Scoped-tier example reads the value immediately, then subscribes in a fire-and-forget background task so module load isn't blocked, guarding on change so the first (current-value) yield doesn't trigger a redundant rebuild. The global/process.env tier stays reload-only (subscribe there is not live), so its example is unchanged. - Outward links to the Harper secrets docs (reference/v5/security/secrets): one contextual link by the code example, one in the config page toolbar. Docs backing the example: HarperFast/documentation#584. Co-Authored-By: Claude Opus 4.8 --- .../instance/config/secrets/index.tsx | 11 ++++++- .../instance/secrets/SecretDeliveryPicker.tsx | 11 +++++++ .../secrets/SecretsManager.delivery.test.tsx | 14 ++++++--- .../instance/secrets/accessExample.test.ts | 14 +++++---- .../instance/secrets/accessExample.ts | 30 +++++++++++++------ 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index 174ea2084..968f0ee03 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -153,7 +153,16 @@ export function ConfigSecretsIndex() { ) ); }} - /> + > + + Secrets Docs + + ); } diff --git a/src/features/instance/secrets/SecretDeliveryPicker.tsx b/src/features/instance/secrets/SecretDeliveryPicker.tsx index 69d550e57..974f483dd 100644 --- a/src/features/instance/secrets/SecretDeliveryPicker.tsx +++ b/src/features/instance/secrets/SecretDeliveryPicker.tsx @@ -9,6 +9,9 @@ import { ReactNode } from 'react'; import { SecretTier } from './accessExample'; import { SecretAccessExample } from './SecretAccessExample'; +/** The Harper docs hub for the secrets store — the `secrets` accessor, tiers, and change subscriptions. */ +const SECRETS_DOCS_URL = 'https://docs.harperdb.io/reference/v5/security/secrets'; + export function SecretDeliveryPicker({ name, tier, @@ -61,6 +64,14 @@ export function SecretDeliveryPicker({ How to read it in your component + + Learn more about secrets in the Harper docs + ); diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx index 5426fadf8..9792858ab 100644 --- a/src/features/instance/secrets/SecretsManager.delivery.test.tsx +++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx @@ -58,10 +58,16 @@ describe('SecretsManager delivery tier — Add', () => { renderManager(); await openAddWithKeyValue(); - // Scoped is the default: the example destructures the accessor, never touches process.env. + // Scoped is the default: the example reads the live accessor and subscribes for rotations, + // never touching process.env. expect(exampleText()).toContain("import { secrets } from 'harper';"); - expect(exampleText()).toContain('const { NEW_KEY } = secrets;'); + expect(exampleText()).toContain('let value = secrets.NEW_KEY;'); + expect(exampleText()).toContain("secrets.subscribe('NEW_KEY')"); expect(exampleText()).not.toContain('process.env'); + + // The example links out to the Harper secrets docs for deeper reading. + const docsLink = screen.getByRole('link', { name: /learn more about secrets/i }); + expect(docsLink.getAttribute('href')).toBe('https://docs.harperdb.io/reference/v5/security/secrets'); }); it('submits a scoped secret with its pending grants', async () => { @@ -126,7 +132,7 @@ describe('SecretsManager delivery tier — Edit', () => { selectedName: 'DB_PASSWORD', renderEditExtras: () =>
grants
, }); - await waitFor(() => expect(exampleText()).toContain('const { DB_PASSWORD } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.DB_PASSWORD;')); // Tier matches what's stored (scoped, unchanged), so the live grant_secret editor is safe. expect(screen.getByTestId('live-grants')).toBeTruthy(); }); @@ -144,7 +150,7 @@ describe('SecretsManager delivery tier — Edit', () => { // Flip to scoped without saving: the secret is still processEnv server-side, so a live // grant_secret would be rejected — show the "save first" hint instead of the editor. fireEvent.click(screen.getAllByRole('radio')[0]); - await waitFor(() => expect(exampleText()).toContain('const { TOKEN } = secrets;')); + await waitFor(() => expect(exampleText()).toContain('let value = secrets.TOKEN;')); expect(screen.queryByTestId('live-grants')).toBeNull(); expect(screen.getByText(/save this as a scoped secret first/i)).toBeTruthy(); }); diff --git a/src/features/instance/secrets/accessExample.test.ts b/src/features/instance/secrets/accessExample.test.ts index e29ed726c..20098e615 100644 --- a/src/features/instance/secrets/accessExample.test.ts +++ b/src/features/instance/secrets/accessExample.test.ts @@ -30,22 +30,26 @@ describe('buildSecretAccessExample', () => { expect(buildSecretAccessExample('my-key', 'processEnv')).toContain("const value = process.env['my-key'];"); }); - it('scoped destructures the secrets accessor for identifier names', () => { + it('scoped reads the live accessor and subscribes 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('let value = secrets.DB_PASSWORD;'); + expect(code).toContain("for await (const next of secrets.subscribe('DB_PASSWORD'))"); + expect(code).toContain('if (next === value) continue;'); // skip the redundant startup rebuild 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 }'); + expect(code).toContain("let value = secrets['my.key'];"); + expect(code).toContain("secrets.subscribe('my.key')"); + expect(code).not.toContain('secrets.my.key'); // never dot-access a non-identifier name }); it('falls back to a placeholder name when empty or whitespace', () => { - expect(buildSecretAccessExample('', 'scoped')).toContain(`const { ${SECRET_NAME_PLACEHOLDER} } = secrets;`); + expect(buildSecretAccessExample('', 'scoped')).toContain(`let value = secrets.${SECRET_NAME_PLACEHOLDER};`); + expect(buildSecretAccessExample('', 'scoped')).toContain(`secrets.subscribe('${SECRET_NAME_PLACEHOLDER}')`); expect(buildSecretAccessExample(' ', 'processEnv')).toContain(`process.env.${SECRET_NAME_PLACEHOLDER}`); }); diff --git a/src/features/instance/secrets/accessExample.ts b/src/features/instance/secrets/accessExample.ts index a278b07e1..1307bfea7 100644 --- a/src/features/instance/secrets/accessExample.ts +++ b/src/features/instance/secrets/accessExample.ts @@ -6,7 +6,11 @@ * - '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. + * `secrets` accessor (`import { secrets } from 'harper'`), read at module top level. On this tier + * the accessor is live and `secrets.subscribe('NAME')` streams the current value then each + * rotation (harper#1787), so the example reads the value immediately, then subscribes in a + * background task — module load isn't blocked and the component hot-swaps a rotated secret + * without a restart. (The global tier stays reload-only, so its example is just the read.) * * 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 @@ -46,16 +50,24 @@ export function buildSecretAccessExample(name: string, tier: SecretTier): string ].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)}];`; + // Scoped: the `secrets` accessor is bound to the loading component, so read it at module top + // level (during load), not inside a request handler. The accessor is live on this tier, and + // `secrets.subscribe(name)` streams the current value then each rotation (harper#1787) — so read + // the value immediately, then subscribe in a fire-and-forget task that never blocks module load. + const read = identifier ? `secrets.${key}` : `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, + "// Use the current value right away — read at your module's top level.", + `let value = ${read};`, + '', + '// Then react to rotations without blocking startup. Subscribing in a background', + '// task lets module load finish, so the rest of your app keeps initializing.', + '(async () => {', + ` for await (const next of secrets.subscribe(${quote(key)})) {`, + ' if (next === value) continue; // the first yield is the current value you already have', + ' value = next; // rotated — rebuild anything bound to the secret (e.g. an API client) here', + ' }', + '})();', ].join('\n'); } From 2326b8d86c49c1d88276d8e72a2c9425091acd75 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:35:26 -0400 Subject: [PATCH 2/3] fix(secrets): guard the subscribe example against unhandled rejections Address review on #1501: the fire-and-forget subscribe loop is copy-paste code, so wrap it in try/catch and console.error the failure (surfacing it rather than swallowing it) so a subscription error can't become an unhandled promise rejection. Co-Authored-By: Claude Opus 4.8 --- src/features/instance/secrets/accessExample.test.ts | 1 + src/features/instance/secrets/accessExample.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/instance/secrets/accessExample.test.ts b/src/features/instance/secrets/accessExample.test.ts index 20098e615..9420239a8 100644 --- a/src/features/instance/secrets/accessExample.test.ts +++ b/src/features/instance/secrets/accessExample.test.ts @@ -36,6 +36,7 @@ describe('buildSecretAccessExample', () => { expect(code).toContain('let value = secrets.DB_PASSWORD;'); expect(code).toContain("for await (const next of secrets.subscribe('DB_PASSWORD'))"); expect(code).toContain('if (next === value) continue;'); // skip the redundant startup rebuild + expect(code).toContain('} catch (error) {'); // subscription errors don't become unhandled rejections expect(code).toContain('top level'); // the module-top-level guidance expect(code).not.toContain('process.env'); }); diff --git a/src/features/instance/secrets/accessExample.ts b/src/features/instance/secrets/accessExample.ts index 1307bfea7..bb77be3ac 100644 --- a/src/features/instance/secrets/accessExample.ts +++ b/src/features/instance/secrets/accessExample.ts @@ -64,9 +64,14 @@ export function buildSecretAccessExample(name: string, tier: SecretTier): string '// Then react to rotations without blocking startup. Subscribing in a background', '// task lets module load finish, so the rest of your app keeps initializing.', '(async () => {', - ` for await (const next of secrets.subscribe(${quote(key)})) {`, - ' if (next === value) continue; // the first yield is the current value you already have', - ' value = next; // rotated — rebuild anything bound to the secret (e.g. an API client) here', + ' try {', + ` for await (const next of secrets.subscribe(${quote(key)})) {`, + ' if (next === value) continue; // the first yield is the current value you already have', + ' value = next; // rotated — rebuild anything bound to the secret (e.g. an API client) here', + ' }', + ' } catch (error) {', + ' // Log and keep serving the last value; it stays valid until the next reload.', + ` console.error('secret subscription ended for', ${quote(key)}, error);`, ' }', '})();', ].join('\n'); From 75207c2311458df9c4d0425d059d0a027bce7d92 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 15:31:08 -0400 Subject: [PATCH 3/3] fix(secrets): render the config docs link at the start of the toolbar Address review on #1501: passing the 'Secrets Docs' link as SecretsManager children put it after Refresh/Add (far right), unlike the sshKeys/certificates pages where the docs link is first. Add a dedicated `docsLink` slot rendered before Refresh/Add and use it from the Secrets config page, matching the convention. The `children` slot (used by the .env editor for its raw-editor button) still renders after Add. Regression test asserts the ordering. Co-Authored-By: Claude Opus 4.8 --- .../instance/config/secrets/index.tsx | 21 ++++++++++--------- .../secrets/SecretsManager.delivery.test.tsx | 17 +++++++++++++++ .../instance/secrets/SecretsManager.tsx | 4 ++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index 968f0ee03..fdb7f2637 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -133,6 +133,16 @@ export function ConfigSecretsIndex() { onSelectName={onSelectName} nameHeader="Secret" delivery={true} + docsLink={ + + Secrets Docs + + } grantableComponents={grantableComponents} addDescription="The value is encrypted in your browser against the cluster's secrets key — plaintext never reaches the API, the operation log, or disk. It can be replaced or deleted, but never read back." editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust how applications read it, or delete the secret." @@ -153,16 +163,7 @@ export function ConfigSecretsIndex() { ) ); }} - > - - Secrets Docs - - + /> ); } diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx index 9792858ab..a1d4d5f8c 100644 --- a/src/features/instance/secrets/SecretsManager.delivery.test.tsx +++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx @@ -47,6 +47,23 @@ function exampleText(): string { return document.querySelector('pre code')?.textContent ?? ''; } +describe('SecretsManager toolbar', () => { + it('renders docsLink at the start of the toolbar, before Refresh/Add', () => { + renderManager({ + onRefresh: vi.fn().mockResolvedValue(undefined), + docsLink: ( + + Secrets Docs + + ), + }); + const docs = screen.getByRole('link', { name: /secrets docs/i }); + const refresh = screen.getByRole('button', { name: /refresh/i }); + // docsLink precedes Refresh in DOM order — it sits at the left, matching the sshKeys/certificates pages. + expect(docs.compareDocumentPosition(refresh) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + describe('SecretsManager delivery tier — Add', () => { async function openAddWithKeyValue() { fireEvent.click(screen.getByRole('button', { name: /add/i })); diff --git a/src/features/instance/secrets/SecretsManager.tsx b/src/features/instance/secrets/SecretsManager.tsx index 437c3d4dd..ed7020606 100644 --- a/src/features/instance/secrets/SecretsManager.tsx +++ b/src/features/instance/secrets/SecretsManager.tsx @@ -46,6 +46,7 @@ export function SecretsManager({ onSet, onDelete, renderEditExtras, + docsLink, children, delivery = false, deliveryDefaultTier = 'scoped', @@ -73,6 +74,8 @@ export function SecretsManager({ onDelete?: (key: string) => Promise; /** Extra per-secret content for the edit dialog (e.g. a live grants editor). */ renderEditExtras?: (name: string) => ReactNode; + /** A docs link rendered at the START of the toolbar (before Refresh/Add), matching the sshKeys/certificates config pages. */ + docsLink?: ReactNode; /** Extra toolbar actions, rendered after Refresh/Add. */ children?: ReactNode; /** Enable the delivery-tier chooser (process.env vs scoped) + access examples in the dialogs. */ @@ -115,6 +118,7 @@ export function SecretsManager({ isFetching={isFetching} onRowClick={canManage ? onRowClick : undefined} > + {docsLink} {onRefresh && (