feat: add Repository access settings UI

This commit is contained in:
2026-08-26 11:58:58 +09:00
parent 1fb1dd489d
commit d092074ca4
5 changed files with 339 additions and 6 deletions
@@ -38,14 +38,36 @@ Deno.test("settings section navigation stays under the settings route", () => {
} }
}); });
Deno.test("settings shell advertises no fake browser admin model", () => { Deno.test("settings shell advertises scoped account authority", () => {
assert( assert(
SETTINGS_PERMISSION_NOTICE.includes("no browser user, role, permission"), SETTINGS_PERMISSION_NOTICE.includes("authenticated account authority"),
"notice should explicitly deny a browser permission model", "notice should identify authenticated account authority",
); );
assert( assert(
SETTINGS_PERMISSION_NOTICE.includes("does not create an admin role"), SETTINGS_PERMISSION_NOTICE.includes("current Workspace owner"),
"notice should not imply an admin role exists", "notice should state the Repository secret permission boundary",
);
assert(
SETTINGS_PERMISSION_NOTICE.includes("does not expose secret material"),
"notice should not imply that stored secret material is readable",
);
});
Deno.test("Repository access settings are editable and canonically routed", () => {
const section = SETTINGS_SECTIONS.find((entry) =>
entry.id === "repository-access"
);
assert(
section?.status === "editable",
"Repository Access should be editable",
);
assert(
settingsSectionHref("repository-access") === "/settings/repository-access",
"Repository Access should have a dedicated settings route",
);
assert(
section?.bullets.join("\n").includes("write-only"),
"Repository Access copy should preserve write-only secret semantics",
); );
}); });
@@ -8,6 +8,7 @@ export type SettingsSectionId =
| "runtime-connections" | "runtime-connections"
| "runtime-inventory" | "runtime-inventory"
| "configuration-sources" | "configuration-sources"
| "repository-access"
| "profile-sources" | "profile-sources"
| "backend-config" | "backend-config"
| "workspace-identity"; | "workspace-identity";
@@ -72,7 +73,7 @@ export type RemoteRuntimeTestResponse = {
export const SETTINGS_ROUTE = "/settings"; export const SETTINGS_ROUTE = "/settings";
export const SETTINGS_PERMISSION_NOTICE = export const SETTINGS_PERMISSION_NOTICE =
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority."; "Workspace settings use authenticated account authority and Workspace-scoped typed Backend APIs. Repository secret management requires the current Workspace owner; this surface does not expose secret material or grant Runtime execution authority.";
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ {
@@ -111,6 +112,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.", "Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
], ],
}, },
{
id: "repository-access",
label: "Repository Access",
status: "editable",
summary:
"Manage Workspace-scoped SSH credentials and pinned host keys without exposing stored secret material.",
bullets: [
"Private keys and passphrases are write-only; list and detail responses contain public metadata only.",
"Host trust requires an explicitly pinned key and never uses accept-new or TOFU.",
"Repository bindings are committed through the shared Workspace configuration editor and validated against these records.",
],
},
{ {
id: "profile-sources", id: "profile-sources",
label: "Profile Sources", label: "Profile Sources",
@@ -175,6 +188,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}/runtimes`; return `${SETTINGS_ROUTE}/runtimes`;
case "configuration-sources": case "configuration-sources":
return `${SETTINGS_ROUTE}/configuration`; return `${SETTINGS_ROUTE}/configuration`;
case "repository-access":
return `${SETTINGS_ROUTE}/repository-access`;
case "profile-sources": case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`; return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity": case "workspace-identity":
@@ -31,6 +31,10 @@
<dt>Source</dt> <dt>Source</dt>
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd> <dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
</div> </div>
<div>
<dt>Repository access</dt>
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
</div>
<div> <div>
<dt>Source revision</dt> <dt>Source revision</dt>
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd> <dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
@@ -0,0 +1,242 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageProps } from './$types';
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
let { data }: PageProps = $props();
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
let message = $state<string | null>(null);
let pending = $state(false);
let credentialId = $state('');
let credentialName = $state('');
let privateKey = $state('');
let passphrase = $state('');
let rotateCredentialId = $state<string | null>(null);
let rotatePrivateKey = $state('');
let rotatePassphrase = $state('');
let hostTrustId = $state('');
let hostname = $state('');
let port = $state(22);
let hostKey = $state('');
let hostExpectedRevision = $state<number | null>(null);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
}
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
const response = await fetch(`${base}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let detail = `request failed (${response.status})`;
try {
const payload = (await response.json()) as { error?: string; message?: string };
detail = payload.message ?? payload.error ?? detail;
} catch {
// Do not surface submitted secret values from response bodies.
}
throw new Error(detail);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
async function createCredential() {
pending = true;
message = null;
try {
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
operation_id: operationId('credential-create'),
credential_id: credentialId,
name: credentialName,
private_key: privateKey,
passphrase: passphrase || null
});
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Credential creation failed';
} finally {
pending = false;
}
}
async function rotateCredential(credential: RepositorySshCredential) {
pending = true;
message = null;
try {
const rotated = await request<RepositorySshCredential>(
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
{
operation_id: operationId('credential-rotate'),
expected_revision: credential.current_revision,
private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null
}
);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
rotatePrivateKey = '';
rotatePassphrase = '';
rotateCredentialId = null;
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Credential rotation failed';
} finally {
pending = false;
}
}
async function deleteCredential(credential: RepositorySshCredential) {
if (!confirm(`Delete credential ${credential.credential_id}?`)) return;
pending = true;
message = null;
try {
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
operation_id: operationId('credential-delete'),
expected_revision: credential.current_revision
});
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
message = `Credential ${credential.credential_id} deleted.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Credential deletion failed';
} finally {
pending = false;
}
}
async function createHostTrust() {
pending = true;
message = null;
try {
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
operation_id: operationId('host-trust-create'),
host_trust_id: hostTrustId,
hostname,
port,
host_key: hostKey,
expected_revision: hostExpectedRevision
});
hostTrusts = hostExpectedRevision === null
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedRevision = null;
message = `Host trust ${created.host_trust_id} saved at revision ${created.current_revision}.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Host trust creation failed';
} finally {
pending = false;
}
}
function editHostTrust(hostTrust: RepositorySshHostTrust) {
hostTrustId = hostTrust.host_trust_id;
hostname = hostTrust.hostname;
port = hostTrust.port;
hostKey = hostTrust.host_key;
hostExpectedRevision = hostTrust.current_revision;
}
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
if (!confirm(`Delete host trust ${hostTrust.host_trust_id}?`)) return;
pending = true;
message = null;
try {
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
operation_id: operationId('host-trust-delete'),
expected_revision: hostTrust.current_revision
});
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Host trust deletion failed';
} finally {
pending = false;
}
}
</script>
<svelte:head><title>Repository Access · Yoi Workspace</title></svelte:head>
<section class="card settings-section">
<header class="settings-section-header">
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
<span class="badge success">encrypted</span>
</header>
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
{#if message}<p class="status-message">{message}</p>{/if}
<div class="settings-runtime-list">
<h3>SSH credentials</h3>
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
{#each credentials as credential (credential.credential_id)}
<div class="card">
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
<div class="settings-action-row">
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
</div>
{#if rotateCredentialId === credential.credential_id}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" /></label>
<button type="submit" disabled={pending}>Rotate credential</button>
</form>
{/if}
</div>
{/each}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
<h3>Add SSH credential</h3>
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={passphrase} autocomplete="new-password" /></label>
<button type="submit" disabled={pending}>Add credential</button>
</form>
</div>
<div class="settings-runtime-list">
<h3>Pinned SSH host keys</h3>
{#if hostTrusts.length === 0}<p>No host trust records configured.</p>{/if}
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
<div class="card">
<strong>{hostTrust.hostname}:{hostTrust.port}</strong> <code>{hostTrust.host_trust_id}</code>
<p>{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}</p>
<p>References: {hostTrust.referenced_repositories.join(', ') || 'none'}</p>
<div class="settings-action-row">
<button type="button" onclick={() => editHostTrust(hostTrust)}>Rotate key</button>
<button type="button" class="danger" disabled={pending || hostTrust.referenced_repositories.length > 0} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>
</div>
</div>
{/each}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createHostTrust(); }}>
<h3>{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}</h3>
<label><span>Host trust id</span><input bind:value={hostTrustId} disabled={hostExpectedRevision !== null} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
<label><span>Hostname</span><input bind:value={hostname} required /></label>
<label><span>Port</span><input type="number" bind:value={port} min="1" max="65535" required /></label>
<label><span>OpenSSH public host key (ssh-ed25519)</span><textarea bind:value={hostKey} required rows="4"></textarea></label>
<button type="submit" disabled={pending}>{hostExpectedRevision === null ? 'Add host key' : 'Save new revision'}</button>
{#if hostExpectedRevision !== null}<button type="button" onclick={() => { hostTrustId = ''; hostname = ''; port = 22; hostKey = ''; hostExpectedRevision = null; }}>Cancel</button>{/if}
</form>
</div>
</section>
@@ -0,0 +1,50 @@
import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
export interface RepositorySshCredential {
credential_id: string;
workspace_id: string;
name: string;
public_key_algorithm: string;
public_key_fingerprint: string;
current_revision: number;
status: string;
created_at: string;
rotated_at: string | null;
referenced_repositories: string[];
}
export interface RepositorySshHostTrust {
host_trust_id: string;
workspace_id: string;
hostname: string;
port: number;
key_algorithm: string;
host_key: string;
fingerprint: string;
current_revision: number;
created_at: string;
updated_at: string;
referenced_repositories: string[];
}
export const load: PageLoad = async ({ fetch, params }) => {
const base = `/api/w/${
encodeURIComponent(params.workspaceId)
}/settings/repository-access`;
const [credentialResult, hostTrustResult] = await Promise.all([
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
]);
if (!credentialResult.data || !hostTrustResult.data) {
throw new Error(
credentialResult.error ?? hostTrustResult.error ??
"Repository access settings unavailable",
);
}
return {
workspaceId: params.workspaceId,
credentials: credentialResult.data,
hostTrusts: hostTrustResult.data,
};
};