Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/features/instance/config/secrets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ export function ConfigSecretsIndex() {
onSelectName={onSelectName}
nameHeader="Secret"
delivery={true}
docsLink={
<a
className="inline-block underline text-sm text-muted-foreground hover:text-foreground"
href="https://docs.harperdb.io/reference/v5/security/secrets"
target="_blank"
rel="noreferrer"
>
Secrets Docs
</a>
}
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."
Expand Down
11 changes: 11 additions & 0 deletions src/features/instance/secrets/SecretDeliveryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +64,14 @@ export function SecretDeliveryPicker({
How to read it in your component
</span>
<SecretAccessExample name={name} tier={tier} />
<a
className="inline-block underline text-sm text-muted-foreground hover:text-foreground"
href={SECRETS_DOCS_URL}
target="_blank"
rel="noreferrer"
>
Learn more about secrets in the Harper docs
</a>
</div>
</div>
);
Expand Down
31 changes: 27 additions & 4 deletions src/features/instance/secrets/SecretsManager.delivery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<a href="https://docs.example/secrets" target="_blank" rel="noreferrer">
Secrets Docs
</a>
),
});
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 }));
Expand All @@ -58,10 +75,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 () => {
Expand Down Expand Up @@ -126,7 +149,7 @@ describe('SecretsManager delivery tier — Edit', () => {
selectedName: 'DB_PASSWORD',
renderEditExtras: () => <div data-testid="live-grants">grants</div>,
});
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();
});
Expand All @@ -144,7 +167,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();
});
Expand Down
4 changes: 4 additions & 0 deletions src/features/instance/secrets/SecretsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function SecretsManager({
onSet,
onDelete,
renderEditExtras,
docsLink,
children,
delivery = false,
deliveryDefaultTier = 'scoped',
Expand Down Expand Up @@ -73,6 +74,8 @@ export function SecretsManager({
onDelete?: (key: string) => Promise<unknown>;
/** 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. */
Expand Down Expand Up @@ -115,6 +118,7 @@ export function SecretsManager({
isFetching={isFetching}
onRowClick={canManage ? onRowClick : undefined}
>
{docsLink}
{onRefresh && (
<Button
variant="defaultOutline"
Expand Down
15 changes: 10 additions & 5 deletions src/features/instance/secrets/accessExample.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,27 @@ 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('} 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');
});

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}`);
});

Expand Down
35 changes: 26 additions & 9 deletions src/features/instance/secrets/accessExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,16 +50,29 @@ 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 () => {',
' 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);`,
' }',
'})();',
Comment thread
dawsontoth marked this conversation as resolved.
].join('\n');
}