diff --git a/web/workspace/src/lib/workspace/settings/model.test.ts b/web/workspace/src/lib/workspace/settings/model.test.ts index e00f5721..a8ef3082 100644 --- a/web/workspace/src/lib/workspace/settings/model.test.ts +++ b/web/workspace/src/lib/workspace/settings/model.test.ts @@ -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( - SETTINGS_PERMISSION_NOTICE.includes("no browser user, role, permission"), - "notice should explicitly deny a browser permission model", + SETTINGS_PERMISSION_NOTICE.includes("authenticated account authority"), + "notice should identify authenticated account authority", ); assert( - SETTINGS_PERMISSION_NOTICE.includes("does not create an admin role"), - "notice should not imply an admin role exists", + SETTINGS_PERMISSION_NOTICE.includes("current Workspace owner"), + "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", ); }); diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 2a89efd8..ed76fbf6 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -8,6 +8,7 @@ export type SettingsSectionId = | "runtime-connections" | "runtime-inventory" | "configuration-sources" + | "repository-access" | "profile-sources" | "backend-config" | "workspace-identity"; @@ -72,7 +73,7 @@ export type RemoteRuntimeTestResponse = { export const SETTINGS_ROUTE = "/settings"; 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[] = [ { @@ -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.", ], }, + { + 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", label: "Profile Sources", @@ -175,6 +188,8 @@ export function settingsSectionHref(id: SettingsSectionId): string { return `${SETTINGS_ROUTE}/runtimes`; case "configuration-sources": return `${SETTINGS_ROUTE}/configuration`; + case "repository-access": + return `${SETTINGS_ROUTE}/repository-access`; case "profile-sources": return `${SETTINGS_ROUTE}/profiles`; case "workspace-identity": diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte index c06eb9c8..df07104d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte @@ -31,6 +31,10 @@
Source
{data.repository.item.source.kind} · {data.repository.item.source.uri}
+
+
Repository access
+
Manage SSH credentials and pinned host keys
+
Source revision
{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte new file mode 100644 index 00000000..e1b4225d --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -0,0 +1,242 @@ + + +Repository Access · Yoi Workspace + +
+
+

owner only

Repository Access

+ encrypted +
+

Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.

+ {#if message}

{message}

{/if} + +
+

SSH credentials

+ {#if credentials.length === 0}

No credentials configured.

{/if} + {#each credentials as credential (credential.credential_id)} +
+ {credential.name} {credential.credential_id} +

{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}

+

References: {credential.referenced_repositories.join(', ') || 'none'}

+
+ + +
+ {#if rotateCredentialId === credential.credential_id} +
{ event.preventDefault(); void rotateCredential(credential); }}> + + + +
+ {/if} +
+ {/each} + +
{ event.preventDefault(); void createCredential(); }}> +

Add SSH credential

+ + + + + +
+
+ +
+

Pinned SSH host keys

+ {#if hostTrusts.length === 0}

No host trust records configured.

{/if} + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} +
+ {hostTrust.hostname}:{hostTrust.port} {hostTrust.host_trust_id} +

{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}

+

References: {hostTrust.referenced_repositories.join(', ') || 'none'}

+
+ + +
+
+ {/each} + +
{ event.preventDefault(); void createHostTrust(); }}> +

{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}

+ + + + + + {#if hostExpectedRevision !== null}{/if} +
+
+
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts new file mode 100644 index 00000000..bf6d239b --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -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(fetch, `${base}/credentials`), + loadJson(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, + }; +};