From 4c458cbf74ad51b00be1360d50233d86312c29e0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 23:30:28 +0900 Subject: [PATCH 1/7] feat(web): redesign repository access settings --- web/workspace/deno.json | 2 +- .../workspace/api/repository-access-loader.ts | 32 + .../lib/workspace/repository-access/model.ts | 107 +++ .../src/lib/workspace/styles/settings.css | 318 +++++++++ .../settings/repository-access/+page.svelte | 657 ++++++++++++------ .../settings/repository-access/+page.ts | 54 +- .../test/repository-access/loader.test.ts | 20 +- .../test/repository-access/model.test.ts | 91 +++ .../test/repository-access/ui.test.ts | 212 ++++-- 9 files changed, 1200 insertions(+), 293 deletions(-) create mode 100644 web/workspace/src/lib/workspace/repository-access/model.ts create mode 100644 web/workspace/test/repository-access/model.test.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 293b4ae3b..32b0e4f91 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts index 26c5384e5..b93424a76 100644 --- a/web/workspace/src/lib/workspace/api/repository-access-loader.ts +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -5,6 +5,38 @@ import { RepositoryAccessSchemaError, } from "./repository-access.ts"; +export type RepositoryAccessSectionResult = { + data: T | null; + error: string | null; +}; + +function sectionErrorMessage(cause: unknown, label: string): string { + if (cause && typeof cause === "object" && "body" in cause) { + const body = (cause as { body?: unknown }).body; + if (body && typeof body === "object" && "message" in body) { + const message = (body as { message?: unknown }).message; + if (typeof message === "string") return message; + } + } + return `${label} could not be loaded.`; +} + +export async function loadRepositoryAccessSection( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, + label: string, +): Promise> { + try { + return { + data: await loadRepositoryAccessJson(fetcher, path, parse), + error: null, + }; + } catch (cause) { + return { data: null, error: sectionErrorMessage(cause, label) }; + } +} + export async function loadRepositoryAccessJson( fetcher: typeof fetch, path: string, diff --git a/web/workspace/src/lib/workspace/repository-access/model.ts b/web/workspace/src/lib/workspace/repository-access/model.ts new file mode 100644 index 000000000..9c28f290b --- /dev/null +++ b/web/workspace/src/lib/workspace/repository-access/model.ts @@ -0,0 +1,107 @@ +export type RepositoryAccessFieldErrors = Record; + +const RESOURCE_ID = /^[A-Za-z0-9_.-]+$/; + +function required(value: string, label: string): string | null { + return value.trim() ? null : `${label} is required.`; +} + +function resourceId(value: string, label: string): string | null { + const missing = required(value, label); + if (missing) return missing; + if (value.length > 128) return `${label} must be 128 characters or fewer.`; + if (!RESOURCE_ID.test(value)) { + return `${label} may contain only letters, numbers, period, underscore, and hyphen.`; + } + return null; +} + +function assign( + errors: RepositoryAccessFieldErrors, + field: string, + message: string | null, +): void { + if (message) errors[field] = message; +} + +export function validateGeneratedCredential(input: { + credentialId: string; + name: string; +}): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign( + errors, + "credentialId", + resourceId(input.credentialId, "Credential id"), + ); + assign(errors, "name", required(input.name, "Name")); + if (input.name.length > 200) { + errors.name = "Name must be 200 characters or fewer."; + } + return errors; +} + +export function validateImportedCredential(input: { + credentialId: string; + name: string; + privateKey: string; +}): RepositoryAccessFieldErrors { + const errors = validateGeneratedCredential(input); + assign( + errors, + "privateKey", + required(input.privateKey, "OpenSSH private key"), + ); + return errors; +} + +export function validateCredentialRotation( + privateKey: string, +): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign(errors, "privateKey", required(privateKey, "New private key")); + return errors; +} + +export function validateHostTrust(input: { + hostTrustId: string; + hostname: string; + port: number; + hostKey: string; +}): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign(errors, "hostTrustId", resourceId(input.hostTrustId, "Host trust id")); + assign(errors, "hostname", required(input.hostname, "Hostname")); + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65_535) { + errors.port = "Port must be an integer from 1 to 65535."; + } + assign(errors, "hostKey", required(input.hostKey, "OpenSSH public host key")); + return errors; +} + +export function repositoryAccessRequestError( + status: number, + operation: string, +): Error { + if (status === 401 || status === 403) { + return new Error(`You do not have permission to ${operation}.`); + } + if (status === 409) { + return new Error( + `This item changed before it could be saved. Reload the page, review the current value, and try again.`, + ); + } + if (status === 400 || status === 422) { + return new Error( + `The server rejected the fields for ${operation}. Review the values and try again.`, + ); + } + if (status === 404) { + return new Error( + `The item for ${operation} no longer exists. Reload the page to see the current state.`, + ); + } + return new Error( + `Unable to ${operation}. Repository Access returned status ${status}.`, + ); +} diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index d81fbb496..b0bb6b92e 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -616,6 +616,7 @@ .settings-page { display: grid; + min-width: 0; gap: var(--space-5); } .settings-section-card, @@ -1053,4 +1054,321 @@ .status-message.error { color: var(--danger); } + + .repository-access-page { + display: grid; + min-width: 0; + gap: var(--space-6); + } + + .repository-access-section { + display: grid; + gap: var(--space-4); + min-width: 0; + } + + .repository-access-section + .repository-access-section { + padding-top: var(--space-5); + border-top: 1px solid var(--line); + } + + .repository-access-section h2, + .repository-access-section h3, + .repository-access-section p { + margin: 0; + } + + .repository-access-section-heading, + .repository-access-form-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + } + + .repository-access-section-heading > div, + .repository-access-form-heading > div { + display: grid; + gap: var(--space-1); + min-width: 0; + } + + .repository-access-section-heading p, + .repository-access-form-heading p, + .repository-access-guidance, + .repository-access-form small, + .repository-access-row-actions + small { + color: var(--text-muted); + } + + .repository-access-actions, + .repository-access-row-actions, + .repository-access-form-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + .repository-access-section button { + min-height: 2.25rem; + border-radius: var(--radius-soft); + padding: var(--space-2) var(--space-3); + font: inherit; + font-weight: 700; + cursor: pointer; + } + + .repository-access-section button.primary { + border: 1px solid transparent; + background: var(--accent); + color: var(--bg); + } + + .repository-access-section button.secondary { + border: 1px solid var(--line); + background: var(--bg-raised); + color: var(--text-strong); + } + + .repository-access-section button.danger { + border: 1px solid var(--danger); + background: transparent; + color: var(--danger); + } + + .repository-access-section button:hover:not(:disabled), + .repository-access-section button:focus-visible:not(:disabled) { + background: var(--interactive-hover); + color: var(--text-strong); + } + + .repository-access-section button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .repository-access-table-wrap { + position: relative; + width: 100%; + min-width: 0; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + background: var(--bg-raised); + } + + .repository-access-table { + width: 100%; + min-width: 48rem; + border-collapse: collapse; + font-size: var(--font-size-compact); + line-height: var(--line-height-compact); + } + + .repository-access-binding-table { + min-width: 40rem; + } + + .repository-access-credential-table { + min-width: 62rem; + } + + .repository-access-host-table { + min-width: 56rem; + } + + .repository-access-table th, + .repository-access-table td { + padding: var(--space-3); + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + } + + .repository-access-table th { + color: var(--text-muted); + font-weight: 700; + } + + .repository-access-table tbody tr:last-child td { + border-bottom: 0; + } + + .repository-access-table td { + overflow-wrap: anywhere; + } + + .repository-access-table td > strong, + .repository-access-table td > code, + .repository-access-table td > small { + display: block; + } + + .repository-access-table td > strong { + color: var(--text-strong); + } + + .repository-access-table td > code + code, + .repository-access-table td > strong + code, + .repository-access-table td > strong + small, + .repository-access-table td > code + small, + .repository-access-row-actions + small { + margin-top: var(--space-1); + } + + .repository-access-row-actions { + min-width: max-content; + } + + .repository-access-empty { + display: grid; + gap: var(--space-2); + padding: var(--space-4); + background: var(--bg-subtle); + } + + .repository-access-empty.error { + border-left: 3px solid var(--danger); + } + + .repository-access-notice { + padding-left: var(--space-3); + border-left: 3px solid var(--line-strong); + } + + .repository-access-notice.success { + border-color: var(--success); + } + + .repository-access-notice.error, + .repository-access-section .field-error { + border-color: var(--danger); + color: var(--danger); + } + + .repository-access-form { + display: grid; + gap: var(--space-4); + padding: var(--space-4); + border-left: 3px solid var(--line-strong); + background: var(--bg-subtle); + } + + .repository-access-form-heading > code { + max-width: 28rem; + overflow-wrap: anywhere; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .repository-access-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + + .repository-access-form label { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; + color: var(--text-muted); + font-weight: 700; + } + + .repository-access-form label.wide { + grid-column: 1 / -1; + } + + .repository-access-form input, + .repository-access-form textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-soft); + background: var(--bg-raised); + color: var(--text-strong); + padding: var(--space-2) var(--space-3); + font: inherit; + } + + .repository-access-form textarea { + resize: vertical; + font-family: var(--font-mono); + font-size: var(--font-size-compact); + } + + .repository-access-form input[aria-invalid="true"], + .repository-access-form textarea[aria-invalid="true"] { + border-color: var(--danger); + } + + .repository-access-technical { + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .repository-access-technical summary { + width: fit-content; + cursor: pointer; + color: var(--text-strong); + font-weight: 700; + } + + .repository-access-technical dl { + display: grid; + gap: var(--space-2); + margin: var(--space-3) 0 0; + } + + .repository-access-technical dl div { + display: grid; + grid-template-columns: minmax(8rem, 0.25fr) minmax(0, 1fr); + gap: var(--space-3); + } + + .repository-access-technical dt { + font-weight: 700; + } + + .repository-access-technical dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + } + + .repository-access-guidance { + max-width: 64rem; + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + @media (max-width: 48rem) { + .repository-access-section-heading, + .repository-access-form-heading { + display: grid; + } + + .repository-access-form-grid { + grid-template-columns: 1fr; + } + + .repository-access-form label.wide { + grid-column: auto; + } + + .repository-access-technical dl div { + grid-template-columns: 1fr; + gap: var(--space-1); + } + } } 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 index bac097bc9..979b67110 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -18,8 +18,19 @@ parseRepositorySshPublicKey, } from '$lib/workspace/api/repository-access'; import { readBoundedJson } from '$lib/workspace/api/http'; + import { + repositoryAccessRequestError, + validateCredentialRotation, + validateGeneratedCredential, + validateHostTrust, + validateImportedCredential, + type RepositoryAccessFieldErrors, + } from '$lib/workspace/repository-access/model'; import type { PageProps } from './$types'; + type Notice = { tone: 'success' | 'error'; text: string }; + type CredentialForm = 'generate' | 'import' | null; + let { data }: PageProps = $props(); let credentials = $state(untrack(() => data.credentials)); let publicKeys = $state>( @@ -27,45 +38,69 @@ ); let hostTrusts = $state(untrack(() => data.hostTrusts)); const accessProjection = untrack(() => data.accessProjection); - let message = $state(null); - let pending = $state(false); - let copiedCredentialId = $state(null); + const workspaceDefaultCredentialId = 'workspace-default'; + let activeOperation = $state(null); + let rotateCredentialId = $state(null); + const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); + const busy = $derived(activeOperation !== null); + const selectedCredential = $derived( + rotateCredentialId ? credentials.find((credential) => credential.credential_id === rotateCredentialId) ?? null : null + ); + const sshRepository = $derived(data.repositories?.items.find((repository) => repository.source.kind === 'ssh') ?? null); + const connectionTestHref = $derived( + sshRepository + ? `/w/${encodeURIComponent(data.workspaceId)}/repositories/${encodeURIComponent(sshRepository.repository_key)}` + : `/w/${encodeURIComponent(data.workspaceId)}/settings/repositories` + ); + let copiedCredentialId = $state(null); + let credentialNotice = $state(null); + let hostNotice = $state(null); + let publicKeyNotices = $state>({}); + + let credentialForm = $state(null); let generateCredentialId = $state(''); let generateCredentialName = $state(''); + let generateErrors = $state({}); let credentialId = $state(''); let credentialName = $state(''); let privateKey = $state(''); let passphrase = $state(''); - let rotateCredentialId = $state(null); + let importErrors = $state({}); let rotatePrivateKey = $state(''); let rotatePassphrase = $state(''); + let rotateErrors = $state({}); + let hostEditorOpen = $state(false); let hostTrustId = $state(''); let hostname = $state(''); let port = $state(22); let hostKey = $state(''); let hostExpectedRevision = $state(null); - - const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); - const workspaceDefaultCredentialId = 'workspace-default'; + let hostErrors = $state({}); function operationId(prefix: string): string { return `${prefix}-${crypto.randomUUID()}`; } + function operationError(error: unknown, fallback: string): Notice { + return { tone: 'error', text: error instanceof Error ? error.message : fallback }; + } + function request( path: string, method: string, body: unknown, - parse: (value: unknown) => T + parse: (value: unknown) => T, + operation: string ): Promise; - function request(path: string, method: string, body: unknown, parse: null): Promise; + function request(path: string, method: string, body: unknown, parse: null, operation: string): Promise; async function request( path: string, method: string, body: unknown, - parse: ((value: unknown) => T) | null + parse: ((value: unknown) => T) | null, + operation: string ): Promise { const response = await fetch(`${base}${path}`, { method, @@ -74,7 +109,7 @@ }); if (!response.ok) { await response.body?.cancel(); - throw new Error(`Repository Access request failed with status ${response.status}.`); + throw repositoryAccessRequestError(response.status, operation); } if (parse === null) { if (response.status !== 204) { @@ -84,9 +119,7 @@ await response.body?.cancel(); return undefined; } - if (response.status === 204) { - throw new Error('Repository Access returned an unexpected empty response.'); - } + if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.'); const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); return parse(payload); } @@ -98,84 +131,136 @@ ); if (!response.ok) { await response.body?.cancel(); - throw new Error(`Repository Access request failed with status ${response.status}.`); + throw repositoryAccessRequestError(response.status, 'load this public key'); } const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); return parseRepositorySshPublicKey(body); } - async function generateCredential() { - pending = true; - message = null; + async function refreshPublicKey(credentialId: string): Promise { try { - const body: GenerateRepositorySshCredentialRequest = { - operation_id: operationId('credential-generate'), - credential_id: generateCredentialId, - name: generateCredentialName - }; - const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential); - const publicKey = await loadPublicKey(created.credential_id); - credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created]; - publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; - generateCredentialId = ''; - generateCredentialName = ''; - message = `Generated SSH credential ${created.credential_id}`; + const publicKey = await loadPublicKey(credentialId); + publicKeys = { ...publicKeys, [credentialId]: publicKey }; } catch (error) { - message = error instanceof Error ? error.message : 'Failed to generate SSH credential'; - } finally { - pending = false; + publicKeyNotices = { + ...publicKeyNotices, + [credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.') + }; } } - async function copyPublicKey(credentialId: string) { - const publicKey = publicKeys[credentialId]?.public_key; - if (!publicKey) return; + function openCredentialForm(form: Exclude) { + credentialForm = form; + rotateCredentialId = null; + credentialNotice = null; + generateErrors = {}; + importErrors = {}; + } + + function closeCredentialForms() { + credentialForm = null; + generateCredentialId = ''; + generateCredentialName = ''; + credentialId = ''; + credentialName = ''; + privateKey = ''; + passphrase = ''; + generateErrors = {}; + importErrors = {}; + } + + async function generateCredential() { + generateErrors = validateGeneratedCredential({ credentialId: generateCredentialId, name: generateCredentialName }); + if (Object.keys(generateErrors).length > 0 || busy) return; + activeOperation = 'generate-credential'; + credentialNotice = null; try { - await navigator.clipboard.writeText(publicKey); - copiedCredentialId = credentialId; + const body: GenerateRepositorySshCredentialRequest = { + operation_id: operationId('credential-generate'), + credential_id: generateCredentialId.trim(), + name: generateCredentialName.trim() + }; + const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential'); + credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created] + .sort((a, b) => a.credential_id.localeCompare(b.credential_id)); + await refreshPublicKey(created.credential_id); + closeCredentialForms(); + credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` }; + } catch (error) { + credentialNotice = operationError(error, 'Failed to generate the SSH credential.'); + } finally { + activeOperation = null; + } + } + + async function copyPublicKey(credential: RepositorySshCredential) { + if (busy) return; + activeOperation = `copy-${credential.credential_id}`; + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; + try { + const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id); + publicKeys = { ...publicKeys, [credential.credential_id]: publicKey }; + await navigator.clipboard.writeText(publicKey.public_key); + copiedCredentialId = credential.credential_id; + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } }; window.setTimeout(() => { - if (copiedCredentialId === credentialId) copiedCredentialId = null; + if (copiedCredentialId === credential.credential_id) copiedCredentialId = null; }, 1500); - } catch { - message = 'Failed to copy the public key'; + } catch (error) { + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') }; + } finally { + activeOperation = null; } } async function createCredential() { - pending = true; - message = null; + importErrors = validateImportedCredential({ credentialId, name: credentialName, privateKey }); + if (Object.keys(importErrors).length > 0 || busy) return; + activeOperation = 'import-credential'; + credentialNotice = null; try { const body: CreateRepositorySshCredentialRequest = { operation_id: operationId('credential-create'), - credential_id: credentialId, - name: credentialName, + credential_id: credentialId.trim(), + name: credentialName.trim(), private_key: privateKey, passphrase: passphrase || null }; - const created = await request( - '/credentials', - 'POST', - body, - parseRepositorySshCredential - ); - const publicKey = await loadPublicKey(created.credential_id); + const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential'); credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id)); - publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; - credentialId = ''; - credentialName = ''; - message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`; + await refreshPublicKey(created.credential_id); + closeCredentialForms(); + credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential creation failed'; + credentialNotice = operationError(error, 'Credential import failed.'); } finally { privateKey = ''; passphrase = ''; - pending = false; + activeOperation = null; } } + function openCredentialRotation(credential: RepositorySshCredential) { + credentialForm = null; + credentialNotice = null; + rotateCredentialId = credential.credential_id; + rotatePrivateKey = ''; + rotatePassphrase = ''; + rotateErrors = {}; + } + + function closeCredentialRotation() { + rotateCredentialId = null; + rotatePrivateKey = ''; + rotatePassphrase = ''; + rotateErrors = {}; + } + async function rotateCredential(credential: RepositorySshCredential) { - pending = true; - message = null; + rotateErrors = validateCredentialRotation(rotatePrivateKey); + if (Object.keys(rotateErrors).length > 0 || busy) return; + activeOperation = `rotate-${credential.credential_id}`; + credentialNotice = null; try { const body: RotateRepositorySshCredentialRequest = { operation_id: operationId('credential-rotate'), @@ -183,212 +268,360 @@ private_key: rotatePrivateKey, passphrase: rotatePassphrase || null }; - const rotated = await request( + const rotated = await request( `/credentials/${encodeURIComponent(credential.credential_id)}/rotate`, 'POST', body, - parseRepositorySshCredential + parseRepositorySshCredential, + `rotate ${credential.name}` ); - const publicKey = await loadPublicKey(rotated.credential_id); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); - publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey }; - rotateCredentialId = null; - message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`; + await refreshPublicKey(rotated.credential_id); + closeCredentialRotation(); + credentialNotice = { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential rotation failed'; + credentialNotice = operationError(error, 'Credential rotation failed.'); } finally { rotatePrivateKey = ''; rotatePassphrase = ''; - pending = false; + activeOperation = null; } } async function deleteCredential(credential: RepositorySshCredential) { - if (!confirm(`Delete credential ${credential.credential_id}?`)) return; - pending = true; - message = null; + if (credential.referenced_repositories.length > 0 || busy) return; + if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; + activeOperation = `delete-${credential.credential_id}`; + credentialNotice = null; try { const body: DeleteRepositorySshCredentialRequest = { operation_id: operationId('credential-delete'), expected_revision: credential.current_revision }; - await request( - `/credentials/${encodeURIComponent(credential.credential_id)}`, - 'DELETE', - body, - null - ); + await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); const remainingPublicKeys = { ...publicKeys }; delete remainingPublicKeys[credential.credential_id]; publicKeys = remainingPublicKeys; - message = `Credential ${credential.credential_id} deleted.`; + credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential deletion failed'; + credentialNotice = operationError(error, 'Credential deletion failed.'); } finally { - pending = false; + activeOperation = null; } } - async function createHostTrust() { - pending = true; - message = null; - try { - const body: PutRepositorySshHostTrustRequest = { - operation_id: operationId('host-trust-create'), - host_trust_id: hostTrustId, - hostname, - port, - host_key: hostKey, - expected_revision: hostExpectedRevision - }; - const created = await request( - '/host-trusts', - 'POST', - body, - parseRepositorySshHostTrust - ); - 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 openNewHostTrust() { + hostEditorOpen = true; + hostTrustId = ''; + hostname = ''; + port = 22; + hostKey = ''; + hostExpectedRevision = null; + hostErrors = {}; + hostNotice = null; } function editHostTrust(hostTrust: RepositorySshHostTrust) { + hostEditorOpen = true; hostTrustId = hostTrust.host_trust_id; hostname = hostTrust.hostname; port = hostTrust.port; hostKey = hostTrust.host_key; hostExpectedRevision = hostTrust.current_revision; + hostErrors = {}; + hostNotice = null; + } + + function closeHostEditor() { + hostEditorOpen = false; + hostTrustId = ''; + hostname = ''; + port = 22; + hostKey = ''; + hostExpectedRevision = null; + hostErrors = {}; + } + + async function saveHostTrust() { + hostErrors = validateHostTrust({ hostTrustId, hostname, port, hostKey }); + if (Object.keys(hostErrors).length > 0 || busy) return; + activeOperation = hostExpectedRevision === null ? 'add-host-trust' : `rotate-host-${hostTrustId}`; + hostNotice = null; + try { + const body: PutRepositorySshHostTrustRequest = { + operation_id: operationId('host-trust-save'), + host_trust_id: hostTrustId.trim(), + hostname: hostname.trim(), + port, + host_key: hostKey.trim(), + expected_revision: hostExpectedRevision + }; + const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, hostExpectedRevision === null ? 'pin this SSH host key' : 'rotate this pinned SSH host key'); + hostTrusts = hostExpectedRevision === null + ? [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id)) + : hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry); + const action = hostExpectedRevision === null ? 'Pinned' : 'Rotated'; + closeHostEditor(); + hostNotice = { tone: 'success', text: `${action} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` }; + } catch (error) { + hostNotice = operationError(error, 'Failed to save the pinned host key.'); + } finally { + activeOperation = null; + } } async function deleteHostTrust(hostTrust: RepositorySshHostTrust) { - if (!confirm(`Delete host trust ${hostTrust.host_trust_id}?`)) return; - pending = true; - message = null; + if (hostTrust.referenced_repositories.length > 0 || busy) return; + if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return; + activeOperation = `delete-host-${hostTrust.host_trust_id}`; + hostNotice = null; try { const body: DeleteRepositorySshHostTrustRequest = { operation_id: operationId('host-trust-delete'), expected_revision: hostTrust.current_revision }; - await request( - `/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, - 'DELETE', - body, - null - ); + await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`); hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id); - message = `Host trust ${hostTrust.host_trust_id} deleted.`; + hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Host trust deletion failed'; + hostNotice = operationError(error, 'Host key deletion failed.'); } finally { - pending = false; + activeOperation = null; } } Repository Access · Yoi Workspace -
-
-

owner only

Repository Access

- encrypted -
-

The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.

- {#if message}

{message}

{/if} - -
-

Active access projection

-

Config revision {accessProjection.config_revision} · {accessProjection.projection_digest}

- {#if accessProjection.bindings.length === 0}

No repository access bindings are active.

{/if} - {#each accessProjection.bindings as binding (binding.repository_key)} -
- {binding.repository_key} -

{binding.access} · additional credential {binding.credential_id} · always includes {workspaceDefaultCredentialId} · host trust {binding.host_trust_id}

+
+
+
+
+

Repository bindings

+

Explicit overrides for Repository authentication and host identity.

- {/each} -
- -
-

SSH credentials

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

No credentials configured.

{/if} - {#each credentials as credential (credential.credential_id)} -
- {credential.name} {credential.credential_id} - {#if credential.credential_id === workspaceDefaultCredentialId}Workspace default{/if} -

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

- {#if publicKeys[credential.credential_id]} - - - {/if} -

References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}

- {#if credential.credential_id !== workspaceDefaultCredentialId} -
- - -
- {/if} - {#if rotateCredentialId === credential.credential_id} -
{ event.preventDefault(); void rotateCredential(credential); }}> - - - -
- {/if} +
+ {#if accessProjection.bindings.length === 0} +
+ No explicit bindings +

SSH Repositories can still use the Workspace default credential and a unique pinned key that matches their host and port.

- {/each} + {:else} + +
+ + + + {#each accessProjection.bindings as binding (binding.repository_key)} + + + + + + + {/each} + +
RepositoryAccessAuthenticationHost identity
{binding.repository_key}Explicit binding{binding.access === 'read_write' ? 'Read and write' : 'Read only'}{workspaceDefaultCredentialId}plus {binding.credential_id}{binding.host_trust_id}
+
+ {/if} +
+ Technical details +
+
Config revision
{accessProjection.config_revision}
+
Projection digest
{accessProjection.projection_digest}
+
+
+
-
{ event.preventDefault(); void generateCredential(); }}> -

Generate Repository SSH credential

-

Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.

- - - -
- -
{ event.preventDefault(); void createCredential(); }}> -

Import existing 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'}

-
- - +
+
+
+

SSH credentials

+

Keys this Workspace can offer when it authenticates to an SSH Repository.

+
+ {#if !data.credentialsError} +
+ +
-
- {/each} + {/if} +
-
{ event.preventDefault(); void createHostTrust(); }}> -

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

- - - - - - {#if hostExpectedRevision !== null}{/if} -
-
-
+ {#if credentialNotice} +

{credentialNotice.text}

+ {/if} + + {#if data.credentialsError} + + {:else if credentials.length === 0} +
+ No SSH credentials are available +

Generate a credential to get a new public key, or import an existing Ed25519 private key.

+
+ {:else} + +
+ + + + {#each credentials as credential (credential.credential_id)} + + + + + + + + {/each} + +
CredentialRoleFingerprintUsed byActions
{credential.name}{credential.credential_id}{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}{credential.public_key_algorithm}{credential.public_key_fingerprint} + {#if credential.credential_id === workspaceDefaultCredentialId} + All SSH Repositories + {:else if credential.referenced_repositories.length > 0} + {credential.referenced_repositories.join(', ')} + {:else} + Not referenced + {/if} + +
+ + {#if credential.credential_id !== workspaceDefaultCredentialId} + + {#if credential.referenced_repositories.length === 0} + + {/if} + {/if} +
+ {#if publicKeyNotices[credential.credential_id]} + {publicKeyNotices[credential.credential_id].text} + {:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError} + {data.defaultPublicKeyError} Use Copy public key to retry. + {:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId} + Remove Repository references before deletion. + {/if} +
+
+ {/if} + + {#if credentialForm === 'generate'} +
{ event.preventDefault(); void generateCredential(); }}> +

Generate SSH credential

Creates an Ed25519 key. Copy the public key to the Repository provider after saving.

+
+ + +
+
+
+ {:else if credentialForm === 'import'} +
{ event.preventDefault(); void createCredential(); }}> +

Import SSH credential

The private key and optional passphrase are write-only and cleared after every save attempt.

+
+ + + + +
+
+
+ {/if} + + {#if selectedCredential} +
{ event.preventDefault(); void rotateCredential(selectedCredential); }}> +
+

Rotate {selectedCredential.name}

Used by {selectedCredential.referenced_repositories.join(', ') || 'no Repositories'}. Existing provider keys must be replaced with the new public key after rotation.

+ {selectedCredential.public_key_fingerprint} +
+
+ + +
+
+
+ {/if} + + {#if credentials.length > 0 && !data.credentialsError} +
+ Credential revisions +
{#each credentials as credential (credential.credential_id)}
{credential.credential_id}
Revision {credential.current_revision}
{/each}
+
+ {/if} + + +
+
+
+

Pinned SSH host keys

+

Keys used to verify the identity of a specific SSH host and port.

+
+ {#if !data.hostTrustsError}{/if} +
+ + {#if hostNotice}

{hostNotice.text}

{/if} + + {#if data.hostTrustsError} + + {:else if hostTrusts.length === 0} +
+ No SSH host keys are pinned +

Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.

+ {sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'} +
+ {:else} + +
+ + + + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} + + + + + + + {/each} + +
Host and portFingerprintUsed byActions
{hostTrust.hostname}:{hostTrust.port}{hostTrust.host_trust_id}{hostTrust.key_algorithm}{hostTrust.fingerprint}{hostTrust.referenced_repositories.join(', ') || 'Not referenced'} +
+ + {#if hostTrust.referenced_repositories.length === 0}{/if} +
+ {#if hostTrust.referenced_repositories.length > 0}Remove Repository references before deletion.{/if} +
+
+ {/if} + +

A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.

+ {sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'} + + {#if hostEditorOpen} +
{ event.preventDefault(); void saveHostTrust(); }}> +
+

{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate ${hostname}:${port}`}

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : 'The new key replaces the current trusted identity for this host and port.'}

+ {#if hostExpectedRevision !== null}{hostTrusts.find((entry) => entry.host_trust_id === hostTrustId)?.fingerprint}{/if} +
+
+ + + + +
+
+
+ {/if} + + {#if hostTrusts.length > 0 && !data.hostTrustsError} +
+ Host key revisions +
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
{hostTrust.host_trust_id}
Revision {hostTrust.current_revision}
{/each}
+
+ {/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 index dc1905d2f..a17e8bb5f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -5,9 +5,14 @@ import { parseRepositorySshHostTrusts, parseRepositorySshPublicKey, } from "$lib/workspace/api/repository-access"; -import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader"; +import { + loadRepositoryAccessJson, + loadRepositoryAccessSection, +} from "$lib/workspace/api/repository-access-loader"; import type { PageLoad } from "./$types"; +const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default"; + export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const accessProjection = await loadRepositoryAccessJson( @@ -15,39 +20,50 @@ export const load: PageLoad = async ({ fetch, params }) => { workspaceApiPath(workspaceId, "/settings/repository-access"), parseRepositoryAccessProjection, ); - const [credentials, hostTrusts] = await Promise.all([ - loadRepositoryAccessJson( + const [credentialsResult, hostTrustsResult] = await Promise.all([ + loadRepositoryAccessSection( fetch, workspaceApiPath(workspaceId, "/settings/repository-access/credentials"), parseRepositorySshCredentials, + "SSH credentials", ), - loadRepositoryAccessJson( + loadRepositoryAccessSection( fetch, workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"), parseRepositorySshHostTrusts, + "Pinned SSH host keys", ), ]); - const publicKeys = await Promise.all( - credentials.map((credential) => - loadRepositoryAccessJson( - fetch, - workspaceApiPath( - workspaceId, - `/settings/repository-access/credentials/${ - encodeURIComponent(credential.credential_id) - }/public-key`, - ), - parseRepositorySshPublicKey, - ) - ), + const credentials = credentialsResult.data ?? []; + const defaultCredential = credentials.find( + (credential) => + credential.credential_id === WORKSPACE_DEFAULT_CREDENTIAL_ID, ); + const defaultPublicKeyResult = defaultCredential + ? await loadRepositoryAccessSection( + fetch, + workspaceApiPath( + workspaceId, + `/settings/repository-access/credentials/${ + encodeURIComponent(defaultCredential.credential_id) + }/public-key`, + ), + parseRepositorySshPublicKey, + "The Workspace default public key", + ) + : { data: null, error: null }; return { workspaceId, credentials, - publicKeys, - hostTrusts, + credentialsError: credentialsResult.error, + publicKeys: defaultPublicKeyResult.data + ? [defaultPublicKeyResult.data] + : [], + defaultPublicKeyError: defaultPublicKeyResult.error, + hostTrusts: hostTrustsResult.data ?? [], + hostTrustsError: hostTrustsResult.error, accessProjection, }; }; diff --git a/web/workspace/test/repository-access/loader.test.ts b/web/workspace/test/repository-access/loader.test.ts index 21b46be31..1df716f69 100644 --- a/web/workspace/test/repository-access/loader.test.ts +++ b/web/workspace/test/repository-access/loader.test.ts @@ -1,4 +1,7 @@ -import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts"; +import { + loadRepositoryAccessJson, + loadRepositoryAccessSection, +} from "../../src/lib/workspace/api/repository-access-loader.ts"; import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts"; type HttpFailure = { status?: number; body?: { message?: string } }; @@ -90,6 +93,21 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502 } }); +Deno.test("Repository Access section loader contains one failed sibling", async () => { + const result = await loadRepositoryAccessSection( + () => Promise.resolve(new Response(null, { status: 503 })), + "/api/w/workspace-1/settings/repository-access/credentials", + (value) => value, + "SSH credentials", + ); + if (result.data !== null) { + throw new Error("failed section must not expose data"); + } + if (result.error !== "Repository Access request failed with status 503.") { + throw new Error(`unexpected scoped error: ${String(result.error)}`); + } +}); + Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => { const upstreamSecret = "secret-ref-must-not-leak"; const failure = await captureHttpFailure( diff --git a/web/workspace/test/repository-access/model.test.ts b/web/workspace/test/repository-access/model.test.ts new file mode 100644 index 000000000..46c2a88b3 --- /dev/null +++ b/web/workspace/test/repository-access/model.test.ts @@ -0,0 +1,91 @@ +import { + repositoryAccessRequestError, + validateCredentialRotation, + validateGeneratedCredential, + validateHostTrust, + validateImportedCredential, +} from "../../src/lib/workspace/repository-access/model.ts"; + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("Repository Access validates disclosed credential forms before mutation", () => { + const generated = validateGeneratedCredential({ + credentialId: "invalid id", + name: "", + }); + assert( + generated.credentialId?.includes("letters"), + "invalid credential id should have a field error", + ); + assert( + generated.name === "Name is required.", + "missing name should have a field error", + ); + + const imported = validateImportedCredential({ + credentialId: "deploy-key", + name: "Deploy key", + privateKey: "", + }); + assert( + imported.privateKey?.includes("required"), + "missing private key should have a field error", + ); + assert( + Object.keys(validateCredentialRotation(" ")).length === 1, + "rotation should reject an empty private key", + ); +}); + +Deno.test("Repository Access validates host identity fields in the editor scope", () => { + const errors = validateHostTrust({ + hostTrustId: "host trust", + hostname: "", + port: 70_000, + hostKey: "", + }); + for (const field of ["hostTrustId", "hostname", "port", "hostKey"]) { + assert( + Boolean(errors[field]), + `missing host trust field error for ${field}`, + ); + } + assert( + Object.keys(validateHostTrust({ + hostTrustId: "github-com", + hostname: "github.com", + port: 22, + hostKey: "ssh-ed25519 fixture-public-host-key", + })).length === 0, + "valid host trust fields should pass", + ); +}); + +Deno.test("Repository Access distinguishes permission, conflict, field, and missing-item failures", () => { + assert( + repositoryAccessRequestError(403, "rotate the key").message.includes( + "permission", + ), + "403 should explain permission", + ); + assert( + repositoryAccessRequestError(409, "rotate the key").message.includes( + "Reload", + ), + "409 should explain revision recovery", + ); + assert( + repositoryAccessRequestError(422, "rotate the key").message.includes( + "fields", + ), + "422 should point to fields", + ); + assert( + repositoryAccessRequestError(404, "rotate the key").message.includes( + "no longer exists", + ), + "404 should explain stale target", + ); +}); diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/ui.test.ts index c9b785062..f484b9536 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/ui.test.ts @@ -19,24 +19,25 @@ const loaderSource = await Deno.readTextFile( import.meta.url, ), ); +const settingsCss = await Deno.readTextFile( + new URL("../../src/lib/workspace/styles/settings.css", import.meta.url), +); -test("Repository Access Web code consumes server-api legacy generated DTOs", () => { +test("Repository Access consumes generated contracts and validates every response boundary", () => { assert( source.includes("$lib/generated/repository-access-api"), "mutation code should import generated request and response contracts", ); - assert( - loaderSource.includes("parseRepositorySshCredentials") && - loaderSource.includes("parseRepositorySshHostTrusts") && - loaderSource.includes("parseRepositorySshPublicKey") && - loaderSource.includes("parseRepositoryAccessProjection"), - "loader should validate unknown JSON before exposing generated DTOs to Svelte", - ); - assert( - loaderSource.indexOf('"/settings/repository-access"') < - loaderSource.indexOf("Promise.all"), - "loader should check Repository Access permission before starting list preloads", - ); + for ( + const parser of [ + "parseRepositorySshCredentials", + "parseRepositorySshHostTrusts", + "parseRepositorySshPublicKey", + "parseRepositoryAccessProjection", + ] + ) { + assert(loaderSource.includes(parser), `loader should use ${parser}`); + } for ( const duplicate of [ "interface RepositorySshCredential", @@ -51,94 +52,185 @@ test("Repository Access Web code consumes server-api legacy generated DTOs", () } }); -test("Repository Access renders the shared access projection fields", () => { +test("Repository Access authorizes first and preserves independently available sections", () => { + assert( + loaderSource.indexOf('"/settings/repository-access"') < + loaderSource.indexOf("Promise.all"), + "loader should check Repository Access permission before section preloads", + ); + assert( + loaderSource.match(/loadRepositoryAccessSection/g)?.length === 4, + "credentials, host trusts, and the default public key should have independent failures", + ); for ( const field of [ + "credentialsError", + "hostTrustsError", + "defaultPublicKeyError", + ] + ) { + assert( + source.includes(`data.${field}`), + `page should render scoped ${field}`, + ); + } + assert( + loaderSource.includes("WORKSPACE_DEFAULT_CREDENTIAL_ID") && + !loaderSource.includes("credentials.map("), + "loader should preload only the default public key instead of every public key", + ); +}); + +test("Repository Access presents current state as tables with technical disclosure", () => { + for ( + const token of [ + "Repository bindings", + "SSH credentials", + "Pinned SSH host keys", + "repository-access-table-wrap", + "Technical details", + "Credential revisions", + "Host key revisions", "accessProjection.config_revision", "accessProjection.projection_digest", - "accessProjection.bindings", "binding.repository_key", "binding.credential_id", "binding.host_trust_id", "binding.access", ] ) { - assert(source.includes(field), `missing access projection field ${field}`); + assert( + source.includes(token), + `missing information structure token ${token}`, + ); } + assert( + !source.includes('class="card'), + "Repository Access should not nest generic cards", + ); + assert( + !source.includes("

Repository Access

"), + "the route title should remain owned by the Header", + ); + assert( + !source.includes("Secret value; it cannot be read back after import.{#if importErrors.privateKey}{importErrors.privateKey}{/if} -
+
{/if} {#if selectedCredential} -
{ event.preventDefault(); void rotateCredential(selectedCredential); }}> + { event.preventDefault(); void rotateCredential(selectedCredential); }}>
-

Rotate {selectedCredential.name}

Used by {selectedCredential.referenced_repositories.join(', ') || 'no Repositories'}. Existing provider keys must be replaced with the new public key after rotation.

+

Rotate {selectedCredential.name}

{selectedCredential.referenced_repositories.length > 0 ? `Explicitly referenced by ${selectedCredential.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this credential. '}Every external provider where the current public key is installed must be updated after rotation.

{selectedCredential.public_key_fingerprint}
+ {#if credentialFormNotice}

{credentialFormNotice.text}

{/if}
- +
-
+
{/if} @@ -554,13 +681,13 @@ {/if} -
+

Pinned SSH host keys

Keys used to verify the identity of a specific SSH host and port.

- {#if !data.hostTrustsError}{/if} + {#if !data.hostTrustsError}{/if}
{#if hostNotice}

{hostNotice.text}

{/if} @@ -577,19 +704,23 @@
- + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} - + - + {/each} @@ -602,18 +733,22 @@ {sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'} {#if hostEditorOpen} - { event.preventDefault(); void saveHostTrust(); }}> + { event.preventDefault(); void saveHostTrust(); }}>
-

{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate ${hostname}:${port}`}

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : 'The new key replaces the current trusted identity for this host and port.'}

- {#if hostExpectedRevision !== null}{hostTrusts.find((entry) => entry.host_trust_id === hostTrustId)?.fingerprint}{/if} +
+

{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}

+

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}

+
+ {#if hostExpectedRevision !== null}
Current fingerprint{editingHostTrust?.fingerprint}
{/if}
+ {#if hostFormNotice}

{hostFormNotice.text}

{/if}
- - - - + + + +
-
+
{/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 index a17e8bb5f..a152808c6 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -6,7 +6,7 @@ import { parseRepositorySshPublicKey, } from "$lib/workspace/api/repository-access"; import { - loadRepositoryAccessJson, + loadRepositoryAccessPermissionGate, loadRepositoryAccessSection, } from "$lib/workspace/api/repository-access-loader"; import type { PageLoad } from "./$types"; @@ -15,10 +15,11 @@ const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default"; export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; - const accessProjection = await loadRepositoryAccessJson( + const accessProjectionResult = await loadRepositoryAccessPermissionGate( fetch, workspaceApiPath(workspaceId, "/settings/repository-access"), parseRepositoryAccessProjection, + "Repository bindings", ); const [credentialsResult, hostTrustsResult] = await Promise.all([ loadRepositoryAccessSection( @@ -64,6 +65,7 @@ export const load: PageLoad = async ({ fetch, params }) => { defaultPublicKeyError: defaultPublicKeyResult.error, hostTrusts: hostTrustsResult.data ?? [], hostTrustsError: hostTrustsResult.error, - accessProjection, + accessProjection: accessProjectionResult.data, + accessProjectionError: accessProjectionResult.error, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts new file mode 100644 index 000000000..eea952d4c --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -0,0 +1,282 @@ +// @vitest-environment happy-dom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import RepositoryAccessPage from "./+page.svelte"; +import type { PageProps } from "./$types"; + +const defaultCredential = { + credential_id: "workspace-default", + workspace_id: "workspace-1", + name: "Workspace default", + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:default", + current_revision: 3, + status: "active", + created_at: "2026-01-01T00:00:00Z", + rotated_at: null, + referenced_repositories: [], +}; + +const deployCredential = { + credential_id: "deploy", + workspace_id: "workspace-1", + name: "Deploy key", + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:deploy", + current_revision: 2, + status: "active", + created_at: "2026-01-01T00:00:00Z", + rotated_at: null, + referenced_repositories: [], +}; + +const hostTrust = { + host_trust_id: "github-com", + workspace_id: "workspace-1", + hostname: "github.com", + port: 22, + key_algorithm: "ssh-ed25519", + host_key: "ssh-ed25519 AAAA-current", + fingerprint: "SHA256:host-current", + current_revision: 4, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + referenced_repositories: [], +}; + +function pageData() { + return { + workspaceId: "workspace-1", + credentials: [defaultCredential, deployCredential], + credentialsError: null, + publicKeys: [{ + credential_id: "workspace-default", + current_revision: 3, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:default", + public_key: "ssh-ed25519 AAAA-default", + }], + defaultPublicKeyError: null, + hostTrusts: [hostTrust], + hostTrustsError: null, + accessProjection: { + workspace_id: "workspace-1", + config_revision: 1, + projection_digest: "sha256:projection", + bindings: [], + }, + accessProjectionError: null, + repositories: { + items: [{ + repository_key: "platform", + source: { kind: "ssh", uri: "git@github.com:team/platform.git" }, + }], + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function renderPage() { + const props = { + data: pageData(), + params: { workspaceId: "workspace-1" }, + } as unknown as PageProps; + return render(RepositoryAccessPage, props); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("opens disclosures with focus, focuses invalid fields, and restores trigger focus on cancel", async () => { + renderPage(); + const trigger = screen.getByRole("button", { name: "Generate credential" }); + + expect(screen.queryByRole("heading", { name: "Generate SSH credential" })) + .toBeNull(); + trigger.focus(); + await fireEvent.click(trigger); + + const idInput = screen.getByRole("textbox", { name: "Credential id" }); + await waitFor(() => expect(document.activeElement).toBe(idInput)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Generate credential" })[1], + ); + expect(await screen.findByText("Credential id is required.")).not.toBeNull(); + expect(document.activeElement).toBe(idInput); + + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Generate SSH credential" })) + .toBeNull() + ); + expect(document.activeElement).toBe(trigger); +}); + +test("fences a deferred submission without disabling unrelated controls and restores focus after success", async () => { + const createResponse = deferred(); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/credentials/generate")) return createResponse.promise; + if (url.endsWith("/credentials/generated/public-key")) { + return Promise.resolve(Response.json({ + credential_id: "generated", + current_revision: 1, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:generated", + public_key: "ssh-ed25519 AAAA-generated", + })); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + renderPage(); + + const trigger = screen.getByRole("button", { name: "Generate credential" }); + trigger.focus(); + await fireEvent.click(trigger); + await fireEvent.input( + screen.getByRole("textbox", { name: "Credential id" }), + { target: { value: "generated" } }, + ); + await fireEvent.input(screen.getByRole("textbox", { name: "Name" }), { + target: { value: "Generated key" }, + }); + const submit = + screen.getAllByRole("button", { name: "Generate credential" })[1]; + await fireEvent.click(submit); + + await waitFor(() => + expect( + (screen.getByRole("button", { name: "Generating…" }) as HTMLButtonElement) + .disabled, + ).toBe(true) + ); + expect( + (screen.getByRole("button", { + name: "Import credential", + }) as HTMLButtonElement).disabled, + ).toBe(false); + expect( + (screen.getByRole("button", { + name: "Add pinned key", + }) as HTMLButtonElement).disabled, + ).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Generating…" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + + createResponse.resolve(Response.json({ + ...deployCredential, + credential_id: "generated", + name: "Generated key", + public_key_fingerprint: "SHA256:generated", + current_revision: 1, + })); + expect(await screen.findByText("Generated key")).not.toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("keeps a deferred row failure on that row while unrelated actions remain enabled", async () => { + const publicKeyResponse = deferred(); + const fetchMock = vi.fn(() => publicKeyResponse.promise); + vi.stubGlobal("fetch", fetchMock); + renderPage(); + + const row = screen.getByText("Deploy key").closest("tr"); + if (!row) throw new Error("missing credential row"); + const copy = within(row).getByRole("button", { name: "Copy public key" }); + await fireEvent.click(copy); + + await waitFor(() => + expect( + (within(row).getByRole("button", { + name: "Loading…", + }) as HTMLButtonElement).disabled, + ).toBe(true) + ); + expect( + (screen.getByRole("button", { + name: "Generate credential", + }) as HTMLButtonElement).disabled, + ).toBe(false); + expect( + (screen.getByRole("button", { + name: "Add pinned key", + }) as HTMLButtonElement).disabled, + ).toBe(false); + await fireEvent.click(within(row).getByRole("button", { name: "Loading…" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + + publicKeyResponse.resolve(new Response(null, { status: 500 })); + const alert = await within(row).findByRole("alert"); + expect(alert.textContent).toContain("Unable to load this public key"); + expect(screen.getAllByRole("alert")).toHaveLength(1); +}); + +test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => + Promise.resolve(Response.json({ + ...hostTrust, + host_key: "ssh-ed25519 AAAA-new", + fingerprint: "SHA256:host-new", + current_revision: 5, + })) + ); + vi.stubGlobal("fetch", fetchMock); + renderPage(); + + const row = screen.getByText("github.com:22").closest("tr"); + if (!row) throw new Error("missing host trust row"); + const trigger = within(row).getByRole("button", { name: "Rotate key" }); + trigger.focus(); + await fireEvent.click(trigger); + + const hostname = screen.getByRole("textbox", { name: "Hostname" }); + const port = screen.getByRole("spinbutton", { name: "Port" }); + const hostKeyInput = screen.getByRole("textbox", { + name: /^OpenSSH public host key/, + }); + expect((hostname as HTMLInputElement).disabled).toBe(true); + expect((port as HTMLInputElement).disabled).toBe(true); + expect(screen.getByText(/keeps the endpoint fixed/).textContent).toContain( + "may also select it automatically", + ); + expect(screen.getByText("Current fingerprint").parentElement?.textContent) + .toContain("SHA256:host-current"); + await waitFor(() => expect(document.activeElement).toBe(hostKeyInput)); + + await fireEvent.input(hostKeyInput, { + target: { value: "ssh-ed25519 AAAA-new" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save new key" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body.hostname).toBe("github.com"); + expect(body.port).toBe(22); + expect(body.expected_revision).toBe(4); + expect(await within(row).findByText(/Rotated the key for github.com:22/)).not + .toBeNull(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Rotate key" }), + ) + ); +}); diff --git a/web/workspace/test/repository-access/loader.test.ts b/web/workspace/test/repository-access/loader.test.ts index 1df716f69..840e2882e 100644 --- a/web/workspace/test/repository-access/loader.test.ts +++ b/web/workspace/test/repository-access/loader.test.ts @@ -1,11 +1,92 @@ import { loadRepositoryAccessJson, + loadRepositoryAccessPermissionGate, loadRepositoryAccessSection, } from "../../src/lib/workspace/api/repository-access-loader.ts"; import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts"; +import { load as loadRepositoryAccessPage } from "../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts"; type HttpFailure = { status?: number; body?: { message?: string } }; +type LoadedPage = { + accessProjection: unknown; + accessProjectionError: string | null; + credentials: Array<{ credential_id: string }>; + credentialsError: string | null; + publicKeys: Array<{ credential_id: string }>; + defaultPublicKeyError: string | null; + hostTrusts: Array<{ host_trust_id: string }>; + hostTrustsError: string | null; +}; + +const projectionFixture = { + workspace_id: "workspace-1", + config_revision: 1, + projection_digest: "sha256:projection", + bindings: [], +}; +const credentialFixture = { + credential_id: "workspace-default", + workspace_id: "workspace-1", + name: "Workspace default", + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + current_revision: 1, + status: "active", + created_at: "2026-01-01T00:00:00Z", + rotated_at: null, + referenced_repositories: [], +}; +const publicKeyFixture = { + credential_id: "workspace-default", + current_revision: 1, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + public_key: "ssh-ed25519 AAAA-workspace-default", +}; +const hostTrustFixture = { + host_trust_id: "github-com", + workspace_id: "workspace-1", + hostname: "github.com", + port: 22, + key_algorithm: "ssh-ed25519", + host_key: "ssh-ed25519 AAAA-host", + fingerprint: "SHA256:host", + current_revision: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + referenced_repositories: [], +}; + +async function loadPageWithFailure(failedSuffix: string): Promise { + const fetcher = (input: RequestInfo | URL): Promise => { + const path = String(input); + if (path.endsWith(failedSuffix)) { + return Promise.resolve(new Response(null, { status: 503 })); + } + if (path.endsWith("/settings/repository-access")) { + return Promise.resolve(Response.json(projectionFixture)); + } + if (path.endsWith("/credentials")) { + return Promise.resolve(Response.json([credentialFixture])); + } + if (path.endsWith("/credentials/workspace-default/public-key")) { + return Promise.resolve(Response.json(publicKeyFixture)); + } + if (path.endsWith("/host-trusts")) { + return Promise.resolve(Response.json([hostTrustFixture])); + } + throw new Error(`unexpected loader request: ${path}`); + }; + return await (loadRepositoryAccessPage as unknown as (event: { + fetch: typeof fetch; + params: { workspaceId: string }; + }) => Promise)({ + fetch: fetcher as typeof fetch, + params: { workspaceId: "workspace-1" }, + }); +} + async function captureHttpFailure( run: () => Promise, expectedStatus: number, @@ -30,6 +111,66 @@ async function captureHttpFailure( throw new Error(`expected bounded ${expectedStatus} error`); } +Deno.test("Repository Access page loader preserves successful siblings for every section failure", async () => { + const projectionFailure = await loadPageWithFailure( + "/settings/repository-access", + ); + if ( + projectionFailure.accessProjection !== null || + !projectionFailure.accessProjectionError + ) { + throw new Error( + "projection failure was not contained to Repository bindings", + ); + } + if ( + projectionFailure.credentials.length !== 1 || + projectionFailure.hostTrusts.length !== 1 + ) { + throw new Error("projection failure hid a successful sibling section"); + } + + const credentialFailure = await loadPageWithFailure("/credentials"); + if ( + credentialFailure.credentials.length !== 0 || + !credentialFailure.credentialsError + ) { + throw new Error("credential failure was not contained to SSH credentials"); + } + if ( + !credentialFailure.accessProjection || + credentialFailure.hostTrusts.length !== 1 + ) { + throw new Error("credential failure hid a successful sibling section"); + } + + const hostFailure = await loadPageWithFailure("/host-trusts"); + if (hostFailure.hostTrusts.length !== 0 || !hostFailure.hostTrustsError) { + throw new Error("host-trust failure was not contained to pinned host keys"); + } + if (!hostFailure.accessProjection || hostFailure.credentials.length !== 1) { + throw new Error("host-trust failure hid a successful sibling section"); + } + + const publicKeyFailure = await loadPageWithFailure( + "/credentials/workspace-default/public-key", + ); + if ( + publicKeyFailure.publicKeys.length !== 0 || + !publicKeyFailure.defaultPublicKeyError + ) { + throw new Error( + "public-key failure was not contained to its credential row", + ); + } + if ( + publicKeyFailure.credentials.length !== 1 || + publicKeyFailure.hostTrusts.length !== 1 + ) { + throw new Error("public-key failure hid successful sections"); + } +}); + for (const status of [401, 403]) { Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => { let requests = 0; @@ -93,6 +234,37 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502 } }); +Deno.test("Repository Access permission gate contains projection failure so sibling sections can load", async () => { + const result = await loadRepositoryAccessPermissionGate( + () => Promise.resolve(new Response(null, { status: 503 })), + "/api/w/workspace-1/settings/repository-access", + (value) => value, + "Repository bindings", + ); + if (result.data !== null) { + throw new Error("failed permission projection must not expose data"); + } + if (result.error !== "Repository Access request failed with status 503.") { + throw new Error( + `unexpected contained projection error: ${String(result.error)}`, + ); + } +}); + +Deno.test("Repository Access permission gate still aborts on forbidden access", async () => { + await captureHttpFailure( + () => + loadRepositoryAccessPermissionGate( + () => Promise.resolve(new Response(null, { status: 403 })), + "/api/w/workspace-1/settings/repository-access", + (value) => value, + "Repository bindings", + ), + 403, + "Repository Access is unavailable for this account.", + ); +}); + Deno.test("Repository Access section loader contains one failed sibling", async () => { const result = await loadRepositoryAccessSection( () => Promise.resolve(new Response(null, { status: 503 })), diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/structure.test.ts similarity index 93% rename from web/workspace/test/repository-access/ui.test.ts rename to web/workspace/test/repository-access/structure.test.ts index f484b9536..67ecba005 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/structure.test.ts @@ -67,6 +67,7 @@ test("Repository Access authorizes first and preserves independently available s "credentialsError", "hostTrustsError", "defaultPublicKeyError", + "accessProjectionError", ] ) { assert( @@ -143,7 +144,7 @@ test("Repository Access explains automatic application without inventing a bindi ); }); -test("Repository Access opens one scoped form with validation, cancellation, and pending fences", () => { +test("Repository Access keeps mutation forms disclosed and operation state scoped", () => { for ( const token of [ "credentialForm === 'generate'", @@ -156,16 +157,20 @@ test("Repository Access opens one scoped form with validation, cancellation, and "validateHostTrust", 'class="field-error"', "aria-invalid", - "disabled={busy}", + "pendingOperations", + "credentialFormNotice", + "credentialRowNotices", + "hostFormNotice", + "hostRowNotices", ">Cancel", - "Generating…", - "Importing…", - "Rotating…", - "Saving…", ] ) { - assert(source.includes(token), `missing scoped form behavior ${token}`); + assert(source.includes(token), `missing scoped form structure ${token}`); } + assert( + !source.includes("disabled={busy}") && !source.includes("activeOperation"), + "unrelated controls must not share page-wide mutation state", + ); const permanentFormStart = source.indexOf(" Date: Thu, 24 Sep 2026 18:48:44 +0900 Subject: [PATCH 3/7] chore: format merged develop changes --- crates/workspace-server/src/server.rs | 33 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5c7abef61..74f890c14 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -10351,11 +10351,10 @@ async fn start_manual_ticket_coder_assignment( } } - match api.store.start_ready_ticket_with_coder_assignment( - record, - &new_id("tasev"), - operation_id, - ) { + match api + .store + .start_ready_ticket_with_coder_assignment(record, &new_id("tasev"), operation_id) + { Ok(assignment) => Ok(assignment), Err(error) => { let diagnostics = @@ -33621,7 +33620,9 @@ mod tests { "idle", "manual assignment must restore the live Worker after capability binding" ); - let runtime_attachments = fixture.runtime.logical_attachments(&fixture.worker.worker_id); + let runtime_attachments = fixture + .runtime + .logical_attachments(&fixture.worker.worker_id); assert_eq!(runtime_attachments.len(), 2); assert_eq!( runtime_attachments @@ -33635,7 +33636,8 @@ mod tests { } #[tokio::test] - async fn manual_coder_assignment_rejects_incomplete_or_mismatched_workdirs_before_state_change() { + async fn manual_coder_assignment_rejects_incomplete_or_mismatched_workdirs_before_state_change() + { for case in ["missing", "undeclared", "selector"] { let fixture = manual_coder_assignment_fixture().await; match case { @@ -33817,15 +33819,22 @@ mod tests { .store .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) .unwrap(); - assert!(links.iter().all(|link| { - link.capabilities == workdir::WorkdirSessionCapabilities::ALL - })); - let runtime_attachments = fixture.runtime.logical_attachments(&fixture.worker.worker_id); + assert!( + links + .iter() + .all(|link| { link.capabilities == workdir::WorkdirSessionCapabilities::ALL }) + ); + let runtime_attachments = fixture + .runtime + .logical_attachments(&fixture.worker.worker_id); assert_eq!(runtime_attachments.len(), 2); assert!(runtime_attachments.iter().all(|attachment| { attachment.capabilities == workdir::WorkdirSessionCapabilities::ALL })); - assert_eq!(fixture.api.runtime.worker(&fixture.worker).unwrap().state, "idle"); + assert_eq!( + fixture.api.runtime.worker(&fixture.worker).unwrap().state, + "idle" + ); } #[tokio::test] From 05378947680d01a3a482fb8a27f6818e3faf652f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 24 Sep 2026 19:13:43 +0900 Subject: [PATCH 4/7] fix(web): fence repository access route state --- .../settings/repository-access/+page.svelte | 200 ++++++++++++++---- .../repository-access.browser.spec.ts | 167 +++++++++++++-- 2 files changed, 304 insertions(+), 63 deletions(-) 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 index f97322c0e..cf20ee0f0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -47,7 +47,8 @@ Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key])) ); let hostTrusts = $state(untrack(() => data.hostTrusts)); - const accessProjection = untrack(() => data.accessProjection); + let accessProjection = $state(untrack(() => data.accessProjection)); + let pageEpoch = 0; let pendingOperations = $state([]); let rotateCredentialId = $state(null); const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); @@ -104,6 +105,52 @@ hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null ); + function resetPageState(next: PageProps['data']): void { + pageEpoch += 1; + credentials = sortCredentials(next.credentials); + publicKeys = Object.fromEntries(next.publicKeys.map((key) => [key.credential_id, key])); + hostTrusts = next.hostTrusts; + accessProjection = next.accessProjection; + pendingOperations = []; + rotateCredentialId = null; + copiedCredentialId = null; + credentialNotice = null; + credentialFormNotice = null; + credentialRowNotices = {}; + hostNotice = null; + hostFormNotice = null; + hostRowNotices = {}; + publicKeyNotices = {}; + credentialForm = null; + generateCredentialId = ''; + generateCredentialName = ''; + generateErrors = {}; + credentialId = ''; + credentialName = ''; + privateKey = ''; + passphrase = ''; + importErrors = {}; + rotatePrivateKey = ''; + rotatePassphrase = ''; + rotateErrors = {}; + credentialReturnFocus = null; + hostEditorOpen = false; + hostTrustId = ''; + hostname = ''; + port = 22; + hostKey = ''; + hostExpectedRevision = null; + hostErrors = {}; + hostReturnFocus = null; + } + + $effect(() => { + const next = data; + untrack(() => resetPageState(next)); + }); + + class StaleRepositoryAccessRequestError extends Error {} + function operationId(prefix: string): string { return `${prefix}-${crypto.randomUUID()}`; } @@ -112,24 +159,51 @@ return { tone: 'error', text: error instanceof Error ? error.message : fallback }; } + function pendingOperationKey(operation: string): string { + return `${pageEpoch}:${operation}`; + } + function isPending(operation: string): boolean { - return pendingOperations.includes(operation); + return pendingOperations.includes(pendingOperationKey(operation)); } - function startOperation(operation: string): boolean { - if (isPending(operation)) return false; - pendingOperations = [...pendingOperations, operation]; - return true; + function startOperation(operation: string): string | null { + const key = pendingOperationKey(operation); + if (pendingOperations.includes(key)) return null; + pendingOperations = [...pendingOperations, key]; + return key; } - function finishOperation(operation: string): void { - pendingOperations = pendingOperations.filter((entry) => entry !== operation); + function finishOperation(operationKey: string): void { + pendingOperations = pendingOperations.filter((entry) => entry !== operationKey); + } + + function isCurrentOperation(operationKey: string): boolean { + return operationKey.startsWith(`${pageEpoch}:`); + } + + function requireCurrentOperation(operationKey: string): void { + if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError(); + } + + function credentialEditorPending(): boolean { + const credentialRotationPrefix = `${pageEpoch}:rotate-`; + const hostRotationPrefix = `${pageEpoch}:rotate-host-`; + return isPending('generate-credential') || isPending('import-credential') || pendingOperations.some((entry) => entry.startsWith(credentialRotationPrefix) && !entry.startsWith(hostRotationPrefix)); + } + + function hostEditorPending(): boolean { + return isPending('add-host-trust') || pendingOperations.some((entry) => entry.startsWith(`${pageEpoch}:rotate-host-`)); } function currentFocus(): HTMLElement | null { return document.activeElement instanceof HTMLElement ? document.activeElement : null; } + function focusIsInside(element: HTMLElement | undefined): boolean { + return element?.contains(document.activeElement) ?? false; + } + async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise { await tick(); const fallback = fallbackId ? document.getElementById(fallbackId) : null; @@ -157,11 +231,16 @@ parse: ((value: unknown) => T) | null, operation: string ): Promise { + const epoch = pageEpoch; const response = await fetch(`${base}${path}`, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + if (epoch !== pageEpoch) { + await response.body?.cancel(); + throw new StaleRepositoryAccessRequestError(); + } if (!response.ok) { await response.body?.cancel(); throw repositoryAccessRequestError(response.status, operation); @@ -176,27 +255,37 @@ } if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.'); const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); + if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError(); return parse(payload); } async function loadPublicKey(credentialId: string): Promise { + const epoch = pageEpoch; const response = await fetch( `${base}/credentials/${encodeURIComponent(credentialId)}/public-key`, { headers: { accept: 'application/json' } } ); + if (epoch !== pageEpoch) { + await response.body?.cancel(); + throw new StaleRepositoryAccessRequestError(); + } if (!response.ok) { await response.body?.cancel(); throw repositoryAccessRequestError(response.status, 'load this public key'); } const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); + if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError(); return parseRepositorySshPublicKey(body); } async function refreshPublicKey(credentialId: string): Promise { + const epoch = pageEpoch; try { const publicKey = await loadPublicKey(credentialId); + if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError(); publicKeys = { ...publicKeys, [credentialId]: publicKey }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) throw error; publicKeyNotices = { ...publicKeyNotices, [credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.') @@ -218,6 +307,7 @@ async function closeCredentialForms(restore = true) { const returnFocus = credentialReturnFocus; + const shouldRestoreFocus = restore && focusIsInside(credentialFormElement); credentialForm = null; generateCredentialId = ''; generateCredentialName = ''; @@ -229,7 +319,7 @@ importErrors = {}; credentialFormNotice = null; credentialReturnFocus = null; - if (restore) await restoreFocus(returnFocus); + if (shouldRestoreFocus) await restoreFocus(returnFocus); } async function generateCredential() { @@ -238,8 +328,8 @@ await focusFirstInvalid(credentialFormElement); return; } - const operation = 'generate-credential'; - if (!startOperation(operation)) return; + const operation = startOperation('generate-credential'); + if (!operation) return; credentialFormNotice = null; try { const body: GenerateRepositorySshCredentialRequest = { @@ -248,11 +338,15 @@ name: generateCredentialName.trim() }; const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential'); + requireCurrentOperation(operation); credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]); await refreshPublicKey(created.credential_id); + requireCurrentOperation(operation); await closeCredentialForms(); + requireCurrentOperation(operation); credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.'); } finally { finishOperation(operation); @@ -260,19 +354,22 @@ } async function copyPublicKey(credential: RepositorySshCredential) { - const operation = `copy-${credential.credential_id}`; - if (!startOperation(operation)) return; + const operation = startOperation(`copy-${credential.credential_id}`); + if (!operation) return; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; try { const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id); + requireCurrentOperation(operation); publicKeys = { ...publicKeys, [credential.credential_id]: publicKey }; await navigator.clipboard.writeText(publicKey.public_key); + requireCurrentOperation(operation); copiedCredentialId = credential.credential_id; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } }; window.setTimeout(() => { - if (copiedCredentialId === credential.credential_id) copiedCredentialId = null; + if (isCurrentOperation(operation) && copiedCredentialId === credential.credential_id) copiedCredentialId = null; }, 1500); } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') }; } finally { finishOperation(operation); @@ -285,8 +382,8 @@ await focusFirstInvalid(credentialFormElement); return; } - const operation = 'import-credential'; - if (!startOperation(operation)) return; + const operation = startOperation('import-credential'); + if (!operation) return; credentialFormNotice = null; try { const body: CreateRepositorySshCredentialRequest = { @@ -297,15 +394,21 @@ passphrase: passphrase || null }; const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential'); + requireCurrentOperation(operation); credentials = sortCredentials([...credentials, created]); await refreshPublicKey(created.credential_id); + requireCurrentOperation(operation); await closeCredentialForms(); + requireCurrentOperation(operation); credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; credentialFormNotice = operationError(error, 'Credential import failed.'); } finally { - privateKey = ''; - passphrase = ''; + if (isCurrentOperation(operation)) { + privateKey = ''; + passphrase = ''; + } finishOperation(operation); } } @@ -324,6 +427,7 @@ async function closeCredentialRotation(restore = true) { const returnFocus = credentialReturnFocus; + const shouldRestoreFocus = restore && focusIsInside(rotationFormElement); const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined; rotateCredentialId = null; rotatePrivateKey = ''; @@ -331,7 +435,7 @@ rotateErrors = {}; credentialFormNotice = null; credentialReturnFocus = null; - if (restore) await restoreFocus(returnFocus, fallbackId); + if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId); } async function rotateCredential(credential: RepositorySshCredential) { @@ -340,8 +444,8 @@ await focusFirstInvalid(rotationFormElement); return; } - const operation = `rotate-${credential.credential_id}`; - if (!startOperation(operation)) return; + const operation = startOperation(`rotate-${credential.credential_id}`); + if (!operation) return; credentialFormNotice = null; try { const body: RotateRepositorySshCredentialRequest = { @@ -357,29 +461,36 @@ parseRepositorySshCredential, `rotate ${credential.name}` ); + requireCurrentOperation(operation); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); await refreshPublicKey(rotated.credential_id); + requireCurrentOperation(operation); finishOperation(operation); await closeCredentialRotation(); + requireCurrentOperation(operation); credentialRowNotices = { ...credentialRowNotices, [rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` } }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; credentialFormNotice = operationError(error, 'Credential rotation failed.'); } finally { - rotatePrivateKey = ''; - rotatePassphrase = ''; + if (isCurrentOperation(operation)) { + rotatePrivateKey = ''; + rotatePassphrase = ''; + } finishOperation(operation); } } async function deleteCredential(credential: RepositorySshCredential) { if (credential.referenced_repositories.length > 0) return; - const operation = `delete-${credential.credential_id}`; - if (isPending(operation)) return; + const operationName = `delete-${credential.credential_id}`; + if (isPending(operationName)) return; if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; - if (!startOperation(operation)) return; + const operation = startOperation(operationName); + if (!operation) return; credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } }; try { const body: DeleteRepositorySshCredentialRequest = { @@ -387,12 +498,14 @@ expected_revision: credential.current_revision }; await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`); + requireCurrentOperation(operation); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); const remainingPublicKeys = { ...publicKeys }; delete remainingPublicKeys[credential.credential_id]; publicKeys = remainingPublicKeys; credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') }; } finally { finishOperation(operation); @@ -429,6 +542,7 @@ async function closeHostEditor(restore = true) { const returnFocus = hostReturnFocus; + const shouldRestoreFocus = restore && focusIsInside(hostFormElement); const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined; hostEditorOpen = false; hostTrustId = ''; @@ -439,7 +553,7 @@ hostErrors = {}; hostFormNotice = null; hostReturnFocus = null; - if (restore) await restoreFocus(returnFocus, fallbackId); + if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId); } async function saveHostTrust() { @@ -454,8 +568,8 @@ hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' }; return; } - const operation = rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust'; - if (!startOperation(operation)) return; + const operation = startOperation(rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust'); + if (!operation) return; hostFormNotice = null; try { const body: PutRepositorySshHostTrustRequest = { @@ -467,15 +581,18 @@ expected_revision: hostExpectedRevision }; const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key'); + requireCurrentOperation(operation); hostTrusts = rotating ? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry) : [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id)); finishOperation(operation); await closeHostEditor(); + requireCurrentOperation(operation); const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` }; if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice }; else hostNotice = notice; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; hostFormNotice = operationError(error, 'Failed to save the pinned host key.'); } finally { finishOperation(operation); @@ -484,10 +601,11 @@ async function deleteHostTrust(hostTrust: RepositorySshHostTrust) { if (hostTrust.referenced_repositories.length > 0) return; - const operation = `delete-host-${hostTrust.host_trust_id}`; - if (isPending(operation)) return; + const operationName = `delete-host-${hostTrust.host_trust_id}`; + if (isPending(operationName)) return; if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return; - if (!startOperation(operation)) return; + const operation = startOperation(operationName); + if (!operation) return; hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } }; try { const body: DeleteRepositorySshHostTrustRequest = { @@ -495,9 +613,11 @@ expected_revision: hostTrust.current_revision }; await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`); + requireCurrentOperation(operation); hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id); hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` }; } catch (error) { + if (error instanceof StaleRepositoryAccessRequestError) return; hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') }; } finally { finishOperation(operation); @@ -563,8 +683,8 @@ {#if !data.credentialsError}
- - + +
{/if} @@ -606,13 +726,13 @@
{#each credentials as credential (credential.credential_id)} - + @@ -726,13 +732,13 @@
Host and portFingerprintUsed byActions
Host and portFingerprintRepository applicationActions
{hostTrust.hostname}:{hostTrust.port}{hostTrust.host_trust_id} {hostTrust.key_algorithm}{hostTrust.fingerprint}{hostTrust.referenced_repositories.join(', ') || 'Not referenced'}{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}
- - {#if hostTrust.referenced_repositories.length === 0}{/if} + + {#if hostTrust.referenced_repositories.length === 0}{/if}
- {#if hostTrust.referenced_repositories.length > 0}Remove Repository references before deletion.{/if} + {#if hostRowNotices[hostTrust.host_trust_id]} + {hostRowNotices[hostTrust.host_trust_id].text} + {:else if hostTrust.referenced_repositories.length > 0} + Remove explicit Repository references before deletion. + {/if}
- {#if credential.credential_id !== workspaceDefaultCredentialId} - + {#if credential.referenced_repositories.length === 0} - + {/if} {/if}
@@ -687,7 +807,7 @@

Pinned SSH host keys

Keys used to verify the identity of a specific SSH host and port.

- {#if !data.hostTrustsError}{/if} + {#if !data.hostTrustsError}{/if} {#if hostNotice}

{hostNotice.text}

{/if} @@ -713,8 +833,8 @@
{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}
- - {#if hostTrust.referenced_repositories.length === 0}{/if} + + {#if hostTrust.referenced_repositories.length === 0}{/if}
{#if hostRowNotices[hostTrust.host_trust_id]} {hostRowNotices[hostTrust.host_trust_id].text} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts index eea952d4c..0b3e98332 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -52,10 +52,13 @@ const hostTrust = { referenced_repositories: [], }; -function pageData() { +function pageData(workspaceId = "workspace-1") { return { - workspaceId: "workspace-1", - credentials: [defaultCredential, deployCredential], + workspaceId, + credentials: [ + { ...defaultCredential, workspace_id: workspaceId }, + { ...deployCredential, workspace_id: workspaceId }, + ], credentialsError: null, publicKeys: [{ credential_id: "workspace-default", @@ -65,10 +68,10 @@ function pageData() { public_key: "ssh-ed25519 AAAA-default", }], defaultPublicKeyError: null, - hostTrusts: [hostTrust], + hostTrusts: [{ ...hostTrust, workspace_id: workspaceId }], hostTrustsError: null, accessProjection: { - workspace_id: "workspace-1", + workspace_id: workspaceId, config_revision: 1, projection_digest: "sha256:projection", bindings: [], @@ -91,12 +94,15 @@ function deferred() { return { promise, resolve }; } -function renderPage() { - const props = { - data: pageData(), - params: { workspaceId: "workspace-1" }, +function pageProps(data = pageData()): PageProps { + return { + data, + params: { workspaceId: data.workspaceId }, } as unknown as PageProps; - return render(RepositoryAccessPage, props); +} + +function renderPage(data = pageData()) { + return render(RepositoryAccessPage, pageProps(data)); } afterEach(() => { @@ -130,7 +136,7 @@ test("opens disclosures with focus, focuses invalid fields, and restores trigger expect(document.activeElement).toBe(trigger); }); -test("fences a deferred submission without disabling unrelated controls and restores focus after success", async () => { +test("fences a deferred editor while an unrelated host draft remains active after success", async () => { const createResponse = deferred(); const fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); @@ -173,15 +179,24 @@ test("fences a deferred submission without disabling unrelated controls and rest (screen.getByRole("button", { name: "Import credential", }) as HTMLButtonElement).disabled, - ).toBe(false); + ).toBe(true); + const deployRow = screen.getByText("Deploy key").closest("tr"); + if (!deployRow) throw new Error("missing deploy credential row"); expect( - (screen.getByRole("button", { - name: "Add pinned key", + (within(deployRow).getByRole("button", { + name: "Rotate", }) as HTMLButtonElement).disabled, - ).toBe(false); + ).toBe(true); + const addHost = screen.getByRole("button", { name: "Add pinned key" }); + expect((addHost as HTMLButtonElement).disabled).toBe(false); await fireEvent.click(screen.getByRole("button", { name: "Generating…" })); expect(fetchMock).toHaveBeenCalledTimes(1); + await fireEvent.click(addHost); + const hostDraft = screen.getByRole("textbox", { name: "Host trust id" }); + await fireEvent.input(hostDraft, { target: { value: "draft-host" } }); + hostDraft.focus(); + createResponse.resolve(Response.json({ ...deployCredential, credential_id: "generated", @@ -190,7 +205,10 @@ test("fences a deferred submission without disabling unrelated controls and rest current_revision: 1, })); expect(await screen.findByText("Generated key")).not.toBeNull(); - await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(screen.getByRole("heading", { name: "Add pinned host key" })).not + .toBeNull(); + expect((hostDraft as HTMLInputElement).value).toBe("draft-host"); + expect(document.activeElement).toBe(hostDraft); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -231,14 +249,96 @@ test("keeps a deferred row failure on that row while unrelated actions remain en expect(screen.getAllByRole("alert")).toHaveLength(1); }); -test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { - const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => - Promise.resolve(Response.json({ +test("rerendering for another Workspace resets local state and fences stale responses", async () => { + const stalePublicKey = deferred(); + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/w/workspace-1/")) return stalePublicKey.promise; + if (url.includes("/api/w/workspace-2/")) { + return Promise.resolve(new Response(null, { status: 500 })); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { rerender } = renderPage(); + + await fireEvent.click( + screen.getByRole("button", { name: "Generate credential" }), + ); + await fireEvent.input( + screen.getByRole("textbox", { name: "Credential id" }), + { + target: { value: "workspace-a-draft" }, + }, + ); + const workspaceARow = screen.getByText("Deploy key").closest("tr"); + if (!workspaceARow) throw new Error("missing Workspace A credential row"); + await fireEvent.click( + within(workspaceARow).getByRole("button", { name: "Copy public key" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + const workspaceBData = { + ...pageData("workspace-2"), + credentials: [ + { + ...defaultCredential, + workspace_id: "workspace-2", + name: "Workspace B default", + }, + { + ...deployCredential, + workspace_id: "workspace-2", + credential_id: "workspace-b-deploy", + name: "Workspace B deploy", + }, + ], + publicKeys: [], + hostTrusts: [{ ...hostTrust, - host_key: "ssh-ed25519 AAAA-new", - fingerprint: "SHA256:host-new", - current_revision: 5, - })) + workspace_id: "workspace-2", + host_trust_id: "workspace-b-host", + hostname: "git.example.test", + }], + accessProjection: { + workspace_id: "workspace-2", + config_revision: 7, + projection_digest: "sha256:workspace-b", + bindings: [], + }, + }; + await rerender(pageProps(workspaceBData)); + + expect(await screen.findByText("Workspace B deploy")).not.toBeNull(); + expect(screen.queryByText("Deploy key")).toBeNull(); + expect(screen.queryByDisplayValue("workspace-a-draft")).toBeNull(); + expect(screen.getByText("git.example.test:22")).not.toBeNull(); + + stalePublicKey.resolve(Response.json({ + credential_id: "deploy", + current_revision: 2, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:deploy", + public_key: "ssh-ed25519 AAAA-stale", + })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.queryByText("Public key copied.")).toBeNull(); + + const workspaceBRow = screen.getByText("Workspace B deploy").closest("tr"); + if (!workspaceBRow) throw new Error("missing Workspace B credential row"); + await fireEvent.click( + within(workspaceBRow).getByRole("button", { name: "Copy public key" }), + ); + expect(await within(workspaceBRow).findByRole("alert")).not.toBeNull(); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain( + "/api/w/workspace-2/settings/repository-access/credentials/workspace-b-deploy/public-key", + ); +}); + +test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { + const rotateResponse = deferred(); + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => + rotateResponse.promise ); vi.stubGlobal("fetch", fetchMock); renderPage(); @@ -272,6 +372,27 @@ test("locks a rotation to its host and port and discloses explicit and automatic expect(body.hostname).toBe("github.com"); expect(body.port).toBe(22); expect(body.expected_revision).toBe(4); + expect( + (screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole("button", { + name: "Add pinned key", + }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + (screen.getByRole("button", { + name: "Generate credential", + }) as HTMLButtonElement).disabled, + ).toBe(false); + + rotateResponse.resolve(Response.json({ + ...hostTrust, + host_key: "ssh-ed25519 AAAA-new", + fingerprint: "SHA256:host-new", + current_revision: 5, + })); expect(await within(row).findByText(/Rotated the key for github.com:22/)).not .toBeNull(); await waitFor(() => From 92fe0ab9b6baccbd478b56e6c5c3d5ca1287e23e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 24 Sep 2026 19:28:44 +0900 Subject: [PATCH 5/7] fix(web): serialize credential key actions --- .../[workspaceId]/settings/repository-access/+page.svelte | 4 ++-- .../repository-access/repository-access.browser.spec.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) 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 index cf20ee0f0..56a35c826 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -730,9 +730,9 @@ {isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'} {#if credential.credential_id !== workspaceDefaultCredentialId} - + {#if credential.referenced_repositories.length === 0} - + {/if} {/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts index 0b3e98332..68b204f30 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -240,6 +240,14 @@ test("keeps a deferred row failure on that row while unrelated actions remain en name: "Add pinned key", }) as HTMLButtonElement).disabled, ).toBe(false); + const rotate = within(row).getByRole("button", { name: "Rotate" }); + const remove = within(row).getByRole("button", { name: "Delete" }); + expect((rotate as HTMLButtonElement).disabled).toBe(true); + expect((remove as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(rotate); + await fireEvent.click(remove); + expect(screen.queryByRole("heading", { name: "Rotate Deploy key" })) + .toBeNull(); await fireEvent.click(within(row).getByRole("button", { name: "Loading…" })); expect(fetchMock).toHaveBeenCalledTimes(1); From 54264e6e3bf32d9d9e673777d1d1bbfae3ae7c1c Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 24 Sep 2026 19:37:25 +0900 Subject: [PATCH 6/7] fix(web): fence credential lifecycle handlers --- .../settings/repository-access/+page.svelte | 20 ++++++++++++------- .../repository-access.browser.spec.ts | 17 +++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) 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 index 56a35c826..74094deba 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -186,6 +186,10 @@ if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError(); } + function credentialLifecyclePending(credentialId: string): boolean { + return isPending(`copy-${credentialId}`) || isPending(`rotate-${credentialId}`) || isPending(`delete-${credentialId}`); + } + function credentialEditorPending(): boolean { const credentialRotationPrefix = `${pageEpoch}:rotate-`; const hostRotationPrefix = `${pageEpoch}:rotate-host-`; @@ -354,6 +358,7 @@ } async function copyPublicKey(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; const operation = startOperation(`copy-${credential.credential_id}`); if (!operation) return; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; @@ -414,6 +419,7 @@ } async function openCredentialRotation(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; credentialReturnFocus = currentFocus(); credentialForm = null; credentialFormNotice = null; @@ -439,6 +445,7 @@ } async function rotateCredential(credential: RepositorySshCredential) { + if (credentialLifecyclePending(credential.credential_id)) return; rotateErrors = validateCredentialRotation(rotatePrivateKey); if (Object.keys(rotateErrors).length > 0) { await focusFirstInvalid(rotationFormElement); @@ -485,9 +492,8 @@ } async function deleteCredential(credential: RepositorySshCredential) { - if (credential.referenced_repositories.length > 0) return; + if (credential.referenced_repositories.length > 0 || credentialLifecyclePending(credential.credential_id)) return; const operationName = `delete-${credential.credential_id}`; - if (isPending(operationName)) return; if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; const operation = startOperation(operationName); if (!operation) return; @@ -711,7 +717,7 @@
CredentialRoleFingerprintRepository applicationActions
{credential.name}{credential.credential_id} {credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'} {credential.public_key_algorithm}{credential.public_key_fingerprint}
- {#if credential.credential_id !== workspaceDefaultCredentialId} - + {#if credential.referenced_repositories.length === 0} - + {/if} {/if}
@@ -789,7 +795,7 @@ -
+
{/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts index 68b204f30..f73e78a9e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -212,7 +212,7 @@ test("fences a deferred editor while an unrelated host draft remains active afte expect(fetchMock).toHaveBeenCalledTimes(2); }); -test("keeps a deferred row failure on that row while unrelated actions remain enabled", async () => { +test("fences same-credential actions during a deferred row failure while unrelated controls remain enabled", async () => { const publicKeyResponse = deferred(); const fetchMock = vi.fn(() => publicKeyResponse.promise); vi.stubGlobal("fetch", fetchMock); @@ -220,6 +220,13 @@ test("keeps a deferred row failure on that row while unrelated actions remain en const row = screen.getByText("Deploy key").closest("tr"); if (!row) throw new Error("missing credential row"); + const rotate = within(row).getByRole("button", { name: "Rotate" }); + await fireEvent.click(rotate); + const rotateSubmit = screen.getByRole("button", { + name: "Rotate credential", + }); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false); + const copy = within(row).getByRole("button", { name: "Copy public key" }); await fireEvent.click(copy); @@ -240,13 +247,14 @@ test("keeps a deferred row failure on that row while unrelated actions remain en name: "Add pinned key", }) as HTMLButtonElement).disabled, ).toBe(false); - const rotate = within(row).getByRole("button", { name: "Rotate" }); const remove = within(row).getByRole("button", { name: "Delete" }); expect((rotate as HTMLButtonElement).disabled).toBe(true); expect((remove as HTMLButtonElement).disabled).toBe(true); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(rotateSubmit); await fireEvent.click(rotate); await fireEvent.click(remove); - expect(screen.queryByRole("heading", { name: "Rotate Deploy key" })) + expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not .toBeNull(); await fireEvent.click(within(row).getByRole("button", { name: "Loading…" })); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -255,6 +263,9 @@ test("keeps a deferred row failure on that row while unrelated actions remain en const alert = await within(row).findByRole("alert"); expect(alert.textContent).toContain("Unable to load this public key"); expect(screen.getAllByRole("alert")).toHaveLength(1); + expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not + .toBeNull(); }); test("rerendering for another Workspace resets local state and fences stale responses", async () => { From a948c2708d132d24b48ffdc0f6407e9728c23499 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 24 Sep 2026 19:45:08 +0900 Subject: [PATCH 7/7] fix(web): invalidate stale rotated public keys --- .../settings/repository-access/+page.svelte | 5 +- .../repository-access.browser.spec.ts | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) 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 index 74094deba..9faaf9d27 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -289,7 +289,10 @@ if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError(); publicKeys = { ...publicKeys, [credentialId]: publicKey }; } catch (error) { - if (error instanceof StaleRepositoryAccessRequestError) throw error; + if (error instanceof StaleRepositoryAccessRequestError || epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError(); + const remainingPublicKeys = { ...publicKeys }; + delete remainingPublicKeys[credentialId]; + publicKeys = remainingPublicKeys; publicKeyNotices = { ...publicKeyNotices, [credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.') diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts index f73e78a9e..63a340328 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -354,6 +354,70 @@ test("rerendering for another Workspace resets local state and fences stale resp ); }); +test("retries public-key loading after a post-rotation refresh failure", async () => { + let publicKeyLoads = 0; + const newPublicKey = "ssh-ed25519 AAAA-new-deploy"; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/credentials/deploy/rotate")) { + return Promise.resolve(Response.json({ + ...deployCredential, + public_key_fingerprint: "SHA256:deploy-new", + current_revision: 3, + rotated_at: "2026-02-01T00:00:00Z", + })); + } + if (url.endsWith("/credentials/deploy/public-key")) { + publicKeyLoads += 1; + if (publicKeyLoads === 1) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.resolve(Response.json({ + credential_id: "deploy", + current_revision: 3, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:deploy-new", + public_key: newPublicKey, + })); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const writeText = vi.spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(); + const data = pageData(); + data.publicKeys.push({ + credential_id: "deploy", + current_revision: 2, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:deploy", + public_key: "ssh-ed25519 AAAA-old-deploy", + }); + renderPage(data); + + const row = screen.getByText("Deploy key").closest("tr"); + if (!row) throw new Error("missing credential row"); + await fireEvent.click(within(row).getByRole("button", { name: "Rotate" })); + await fireEvent.input( + screen.getByRole("textbox", { name: /^New OpenSSH private key/ }), + { target: { value: "-----BEGIN OPENSSH PRIVATE KEY-----" } }, + ); + await fireEvent.click( + screen.getByRole("button", { name: "Rotate credential" }), + ); + + const refreshAlert = await within(row).findByRole("alert"); + expect(refreshAlert.textContent).toContain("Unable to load this public key"); + expect(publicKeyLoads).toBe(1); + + await fireEvent.click( + within(row).getByRole("button", { name: "Copy public key" }), + ); + expect(await within(row).findByText("Public key copied.")).not.toBeNull(); + expect(publicKeyLoads).toBe(2); + expect(writeText).toHaveBeenCalledWith(newPublicKey); +}); + test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { const rotateResponse = deferred(); const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>