diff --git a/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts b/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts
new file mode 100644
index 000000000..3c320e3a5
--- /dev/null
+++ b/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts
@@ -0,0 +1,29 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, expect, it } from 'vitest';
+import { buildCLISteps } from './useCLISteps';
+
+describe('buildCLISteps', () => {
+ it('passes the cluster URL to create-harper, and nothing it no longer accepts', () => {
+ const steps = buildCLISteps('My App', 'https://my-cluster.example.com:9925/');
+ const create = steps.find((step) => step.title === 'Create your awesome app');
+ expect(create?.code).toBe('npm create harper my-app -- --deploymentURL="https://my-cluster.example.com:9925/"');
+ // create-harper dropped --deploymentUsername and the .env password when it moved to `harper login`.
+ for (const step of steps) {
+ expect(step.code).not.toMatch(/deploymentUsername/);
+ expect(step.note ?? '').not.toMatch(/password for this cluster|\.env file/);
+ }
+ });
+
+ it('tells people to take the harper login offer, and that the deploy uses it', () => {
+ const steps = buildCLISteps('my-app', 'https://my-cluster.example.com:9925/');
+ expect(steps.find((step) => step.title === 'Create your awesome app')?.note).toMatch(/harper login/);
+ expect(steps.find((step) => step.title === 'Deploy!')?.note).toMatch(/login from step 2/);
+ });
+
+ it('leaves the URL out when the cluster has none yet', () => {
+ const create = buildCLISteps('my-app', undefined).find((step) => step.title === 'Create your awesome app');
+ expect(create?.code).toBe('npm create harper my-app');
+ });
+});
diff --git a/src/features/instance/applications/components/NewApplication/useCLISteps.tsx b/src/features/instance/applications/components/NewApplication/useCLISteps.tsx
index 96787b9c1..774cd06e0 100644
--- a/src/features/instance/applications/components/NewApplication/useCLISteps.tsx
+++ b/src/features/instance/applications/components/NewApplication/useCLISteps.tsx
@@ -1,16 +1,54 @@
import { isLocalStudio } from '@/config/constants';
import { useInstanceClientParams } from '@/config/useInstanceClient';
-import { useInstanceAuth } from '@/hooks/useAuth';
import { Cluster, Instance } from '@/integrations/api/api.patch';
-import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
import { toKebabCase } from '@/lib/string/to-kebab-case';
import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster';
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
import { useRouteContext } from '@tanstack/react-router';
import { useMemo } from 'react';
+export interface CLIStep {
+ title: string;
+ code: string;
+ note?: string;
+}
+
+/**
+ * The steps follow create-harper's own flow: it takes the cluster URL, offers to run `harper login`,
+ * and stores no username or password in the project.
+ */
+export function buildCLISteps(appName: string, target: string | null | undefined): CLIStep[] {
+ const directoryName = toKebabCase(appName);
+ return [
+ {
+ title: 'Install or update the Harper CLI',
+ code: 'npm install -g harper',
+ },
+ {
+ title: 'Create your awesome app',
+ code: `npm create harper ${directoryName}${target ? ` -- --deploymentURL="${target}"` : ''}`,
+ note: 'You will be asked some easy questions to get you started. When it offers to log in to this cluster,'
+ + ' say yes: harper login saves a token in your home directory, so no password goes into your project.',
+ },
+ {
+ title: 'Start local Harper instance',
+ code: 'npm run dev',
+ note: 'You will be prompted to configure Harper.',
+ },
+ {
+ title: 'Learn, make changes, grow!',
+ code: `cat AGENTS.md`,
+ },
+ {
+ title: 'Deploy!',
+ code: `npm run deploy`,
+ note: "It signs in with the login from step 2. Your project's README explains what a deploy needs in its"
+ + ' Deployment section.',
+ },
+ ];
+}
+
export function useCLISteps(appName: string) {
- const { user } = useInstanceAuth();
const instanceParams = useInstanceClientParams();
const { instance, cluster }: { instance?: Instance; cluster?: Cluster } = useRouteContext({ strict: false });
const target = isLocalStudio
@@ -19,37 +57,5 @@ export function useCLISteps(appName: string) {
? getOperationsUrlForInstance(instance)
: getOperationsUrlForCluster(cluster);
- return useMemo(() => {
- const directoryName = toKebabCase(appName);
- const createArgs = [
- user?.username && ` --deploymentUsername="${user?.username}"`,
- target && ` --deploymentURL="${target}"`,
- ].filter(excludeFalsy).join('');
- return [
- {
- title: 'Install or update the Harper CLI',
- code: 'npm install -g harper',
- },
- {
- title: 'Create your awesome app',
- code: `npm create harper ${directoryName}${createArgs.length ? ' --' : ''}${createArgs}`,
- note: 'You will be asked some easy questions to get you started. To make deployments easy, enter your'
- + ' password for this cluster. It will get saved into your local .env file.',
- },
- {
- title: 'Start local Harper instance',
- code: 'npm run dev',
- note: 'You will be prompted to configure Harper.',
- },
- {
- title: 'Learn, make changes, grow!',
- code: `cat AGENTS.md`,
- },
- {
- title: 'Deploy!',
- code: `npm run deploy`,
- note: 'Remember that .env file? It makes this deployment easy!',
- },
- ];
- }, [appName, target, user?.username]);
+ return useMemo(() => buildCLISteps(appName, target), [appName, target]);
}
diff --git a/src/features/instance/config/index.tsx b/src/features/instance/config/index.tsx
index bee533ac2..8410d02fb 100644
--- a/src/features/instance/config/index.tsx
+++ b/src/features/instance/config/index.tsx
@@ -3,12 +3,14 @@ import { isLocalStudio } from '@/config/constants';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
import { useInstanceManagePermission } from '@/hooks/usePermissions';
import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
+import { supportsTrustPolicies } from '@/integrations/api/instance/oidcTrust/oidcTrust';
import { RegistrationInfoResponse } from '@/integrations/api/instance/status/getRegistrationInfo';
import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB';
import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useLoaderData, useParams } from '@tanstack/react-router';
import {
+ BadgeCheckIcon,
GlobeIcon,
HandshakeIcon,
KeyIcon,
@@ -37,6 +39,7 @@ export function ConfigIndex() {
// (harper#1554). Floor at the earliest 5.2 prerelease so alpha/beta dev builds pass too — a
// plain '5.2.0' gate would exclude every prerelease (SemVer ranks prereleases below the release).
const secretsSupported = wasAReleasedBeforeB('5.2.0-alpha.1', version);
+ const trustPoliciesSupported = supportsTrustPolicies(version);
const { clusterId } = params;
const canManage = useInstanceManagePermission();
@@ -124,6 +127,18 @@ export function ConfigIndex() {
)}
+ {trustPoliciesSupported && (
+
+
+ Trust Policies
+
+
+ )}
instanceConfigRoute,
+ path: 'trust-policies',
+ head: () => ({ meta: [{ title: 'Trust Policies — Harper Fabric' }] }),
+ component: ConfigTrustPoliciesIndex,
+ });
+
const instanceConfigCertificatesRoute = createRoute({
getParentRoute: () => instanceConfigRoute,
path: 'certificates',
@@ -132,6 +140,8 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType ({
+ state: {
+ canManage: true,
+ version: '5.3.0' as string | undefined,
+ policies: [] as unknown[] | undefined,
+ isError: false,
+ isFetching: false,
+ versionError: false,
+ },
+ setEnabled: vi.fn(async () => ({ message: 'ok' })),
+ dropPolicy: vi.fn(async () => ({ message: 'ok' })),
+ refetch: vi.fn(async () => undefined),
+ queryOptionsSeen: [] as Array<{ queryKey: readonly unknown[]; enabled?: boolean }>,
+}));
+
+vi.mock('@tanstack/react-query', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
+ queryOptionsSeen.push(options);
+ if (options.queryKey[0] === 'registration_info') {
+ return {
+ data: state.version === undefined ? undefined : { version: state.version },
+ isError: state.versionError,
+ };
+ }
+ return { data: state.policies, isFetching: state.isFetching, isError: state.isError, refetch };
+ },
+}));
+vi.mock('@/config/useInstanceClient', () => ({
+ useInstanceClientIdParams: () => ({ instanceClient: {}, entityId: 'entity-1' }),
+}));
+vi.mock('@/hooks/usePermissions', () => ({ useInstanceManagePermission: () => state.canManage }));
+vi.mock('@/integrations/api/instance/oidcTrust/oidcTrust', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useSetTrustPolicyEnabled: () => ({ mutateAsync: setEnabled, isPending: false }),
+ useDropTrustPolicy: () => ({ mutateAsync: dropPolicy, isPending: false }),
+}));
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), loading: vi.fn() } }));
+
+function policy(overrides: Partial = {}): OidcTrustPolicy {
+ return {
+ id: 'github-actions-my-app',
+ issuer: 'https://token.actions.githubusercontent.com',
+ audience: 'https://my-cluster.example.com:9925/',
+ claims: { repository_id: '123456', environment: ['production', 'staging'] },
+ user: 'my-app-ci-deploy',
+ operations: ['deploy_component', 'get_job'],
+ enabled: true,
+ description: null,
+ ...overrides,
+ };
+}
+
+function card(id: string) {
+ return screen.getByRole('listitem', { name: `Trust policy ${id}` });
+}
+
+beforeEach(() => {
+ state.canManage = true;
+ state.version = '5.3.0';
+ state.policies = [];
+ state.isError = false;
+ state.isFetching = false;
+ state.versionError = false;
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ queryOptionsSeen.length = 0;
+});
+
+function listQueryEnabled() {
+ return queryOptionsSeen.filter((options) => options.queryKey[1] === 'list_oidc_trust').at(-1)?.enabled;
+}
+
+describe('ConfigTrustPoliciesIndex', () => {
+ it('never asks a non-super user instance for its trust policies', () => {
+ state.canManage = false;
+ render();
+ expect(screen.getByText(/Only super users/)).toBeTruthy();
+ expect(listQueryEnabled()).toBe(false);
+ });
+
+ it('says which Harper version it needs instead of listing on an older instance', () => {
+ state.version = '5.2.15';
+ render();
+ expect(screen.getByText(/need Harper 5.3 or later\. This instance runs 5\.2\.15/)).toBeTruthy();
+ expect(listQueryEnabled()).toBe(false);
+ });
+
+ it('lists what each policy trusts, who it signs in as, and what its token may run', () => {
+ state.policies = [policy()];
+ render();
+ expect(listQueryEnabled()).toBe(true);
+ const item = within(card('github-actions-my-app'));
+ expect(item.getByText('Enabled')).toBeTruthy();
+ expect(item.getByText('https://my-cluster.example.com:9925/')).toBeTruthy();
+ expect(item.getByText('repository_id = 123456')).toBeTruthy();
+ expect(item.getByText('environment = production or staging')).toBeTruthy();
+ expect(item.getByText('my-app-ci-deploy')).toBeTruthy();
+ expect(item.getByText('deploy_component, get_job')).toBeTruthy();
+ });
+
+ it("says an unscoped policy's token carries the user's whole role", () => {
+ state.policies = [policy({ operations: null })];
+ render();
+ expect(within(card('github-actions-my-app')).getByText(/the token carries everything my-app-ci-deploy's role/))
+ .toBeTruthy();
+ });
+
+ it('shows why Harper refuses a policy, and offers only Drop since re-saving would fail the same check', () => {
+ state.policies = [policy({ invalid_reason: "names user 'my-app-ci-deploy', which does not exist" })];
+ render();
+ const item = within(card('github-actions-my-app'));
+ expect(item.getByText("Can't match")).toBeTruthy();
+ expect(item.getByText(/Harper refuses every token for this policy: names user/)).toBeTruthy();
+ expect(item.queryByRole('button', { name: 'Disable' })).toBeNull();
+ expect(item.getByRole('button', { name: 'Drop' })).toBeTruthy();
+ });
+
+ it('renders a malformed row as text instead of crashing the page', () => {
+ state.policies = [
+ policy({
+ id: 'odd',
+ claims: { repository_id: { nested: true } },
+ operations: 'deploy_component',
+ enabled: 'false',
+ }),
+ ];
+ render();
+ const item = within(card('odd'));
+ expect(item.getByText('repository_id = {"nested":true}')).toBeTruthy();
+ // enabled is not a boolean, so the policy is neither shown as live nor offered a toggle.
+ expect(item.getByText('Disabled')).toBeTruthy();
+ expect(item.queryByRole('button', { name: /able$/ })).toBeNull();
+ });
+
+ it('disables an enabled policy from the row it was loaded as, then refetches', async () => {
+ const loaded = policy();
+ state.policies = [loaded];
+ render();
+ fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Disable' }));
+ await waitFor(() => expect(refetch).toHaveBeenCalled());
+ expect(setEnabled).toHaveBeenCalledWith(expect.objectContaining({ loaded, enabled: false }));
+ });
+
+ it('offers Enable on a disabled policy', () => {
+ state.policies = [policy({ enabled: false })];
+ render();
+ expect(within(card('github-actions-my-app')).getByRole('button', { name: 'Enable' })).toBeTruthy();
+ });
+
+ it('drops a policy only after confirmation', async () => {
+ state.policies = [policy()];
+ render();
+ fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Drop' }));
+ expect(dropPolicy).not.toHaveBeenCalled();
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.click(within(dialog).getByRole('button', { name: /Drop/ }));
+ // The row as shown, so the drop can refuse one replaced under the same id since.
+ await waitFor(() => expect(dropPolicy).toHaveBeenCalledWith(expect.objectContaining({ loaded: policy() })));
+ await waitFor(() => expect(refetch).toHaveBeenCalled());
+ });
+
+ it('holds the actions while the list is refetching, so a click never acts on a replaced row', () => {
+ state.policies = [policy()];
+ state.isFetching = true;
+ render();
+ const item = within(card('github-actions-my-app'));
+ expect((item.getByRole('button', { name: 'Disable' }) as HTMLButtonElement).disabled).toBe(true);
+ expect((item.getByRole('button', { name: 'Drop' }) as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it('says so when the instance version cannot be read, instead of loading forever', () => {
+ state.version = undefined;
+ state.versionError = true;
+ render();
+ expect(screen.getByText(/version could not be read/)).toBeTruthy();
+ expect(listQueryEnabled()).toBe(false);
+ });
+
+ it('says when there are no policies, and when they could not be loaded', () => {
+ render();
+ expect(screen.getByText(/No trust policies yet/)).toBeTruthy();
+ cleanup();
+ state.isError = true;
+ render();
+ expect(screen.getByText(/could not be loaded/)).toBeTruthy();
+ });
+});
diff --git a/src/features/instance/config/trustPolicies/index.tsx b/src/features/instance/config/trustPolicies/index.tsx
new file mode 100644
index 000000000..d419fd5f4
--- /dev/null
+++ b/src/features/instance/config/trustPolicies/index.tsx
@@ -0,0 +1,258 @@
+import { ConfirmDeletionModal } from '@/components/ConfirmDeletionModal';
+import { Loading } from '@/components/Loading';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { useInstanceClientIdParams } from '@/config/useInstanceClient';
+import { useInstanceManagePermission } from '@/hooks/usePermissions';
+import { useRefreshClick } from '@/hooks/useRefreshClick';
+import {
+ isTrustPolicyScoped,
+ listOidcTrustQueryOptions,
+ OidcTrustPolicy,
+ supportsTrustPolicies,
+ useDropTrustPolicy,
+ useSetTrustPolicyEnabled,
+} from '@/integrations/api/instance/oidcTrust/oidcTrust';
+import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo';
+import { useQuery } from '@tanstack/react-query';
+import { RefreshCwIcon } from 'lucide-react';
+import { ReactNode, useState } from 'react';
+import { toast } from 'sonner';
+
+const DOCS_URL = 'https://docs.harperdb.io/reference/v5/operations-api/operations#add_oidc_trust';
+
+/** The cluster's OIDC trust policies: list, disable or enable, and drop. */
+export function ConfigTrustPoliciesIndex() {
+ const instanceParams = useInstanceClientIdParams();
+ const canManage = useInstanceManagePermission();
+ const { data: registrationInfo, isError: isVersionError } = useQuery(
+ getRegistrationInfoQueryOptions(instanceParams),
+ );
+ const supported = registrationInfo === undefined ? undefined : supportsTrustPolicies(registrationInfo.version);
+ const { data: policies, isFetching, isError, refetch } = useQuery(
+ listOidcTrustQueryOptions({ ...instanceParams, enabled: canManage && supported === true }),
+ );
+ const onRefreshClick = useRefreshClick(refetch);
+
+ const { mutateAsync: setEnabled, isPending: isSetEnabledPending } = useSetTrustPolicyEnabled();
+ const { mutateAsync: dropPolicy, isPending: isDropPending } = useDropTrustPolicy();
+ const [dropTarget, setDropTarget] = useState();
+ // Held through the refetch too, so a second click never acts on the row the write just replaced.
+ const busy = isSetEnabledPending || isDropPending || isFetching;
+
+ if (!canManage) {
+ return Only super users can view or change trust policies.;
+ }
+ if (supported === undefined) {
+ return isVersionError
+ ? This instance's Harper version could not be read, so its trust policies are not shown.
+ : ;
+ }
+ if (!supported) {
+ return (
+
+ Trust policies need Harper 5.3 or later. This instance runs {registrationInfo?.version}.
+
+ );
+ }
+
+ // Refetched on failure too: the usual failure is that the row changed or vanished, and the error
+ // toast asks the reader to review what is there now.
+ const onToggle = async (policy: OidcTrustPolicy, enabled: boolean) => {
+ try {
+ await setEnabled({ ...instanceParams, loaded: policy, enabled });
+ toast.success(`${enabled ? 'Enabled' : 'Disabled'} trust policy '${policy.id}'`);
+ } finally {
+ await refetch();
+ }
+ };
+
+ const onDropConfirmed = async () => {
+ if (!dropTarget) {
+ return;
+ }
+ try {
+ await dropPolicy({ ...instanceParams, loaded: dropTarget });
+ toast.success(`Dropped trust policy '${dropTarget.id}'`);
+ } finally {
+ setDropTarget(undefined);
+ await refetch();
+ }
+ };
+
+ return (
+
+
+
+
Trust Policies
+
+ A trust policy lets a CI workflow, such as GitHub Actions, sign in as a Harper user with its OIDC identity
+ token instead of a stored password or token. Policies replicate to every node in the cluster.
+