diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index ab4ef23e..97d0ac4e 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -16,3 +16,7 @@ workdir.workspace = true [dev-dependencies] serde_json.workspace = true + +[[example]] +name = "generate_repository_access_types" +required-features = ["typescript"] diff --git a/crates/workspace-api/examples/generate_repository_access_types.rs b/crates/workspace-api/examples/generate_repository_access_types.rs new file mode 100644 index 00000000..48cc2665 --- /dev/null +++ b/crates/workspace-api/examples/generate_repository_access_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::repository_access_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index a57e8544..e147f2b7 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -566,6 +566,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest { /// /// Secret references and secret material are deliberately not part of this DTO. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySshCredential { pub credential_id: String, @@ -573,6 +574,7 @@ pub struct RepositorySshCredential { pub name: String, pub public_key_algorithm: String, pub public_key_fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub current_revision: u64, pub status: String, pub created_at: String, @@ -582,6 +584,7 @@ pub struct RepositorySshCredential { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CreateRepositorySshCredentialRequest { pub operation_id: String, @@ -593,9 +596,11 @@ pub struct CreateRepositorySshCredentialRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RotateRepositorySshCredentialRequest { pub operation_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub expected_revision: u64, pub private_key: String, #[serde(default)] @@ -603,14 +608,17 @@ pub struct RotateRepositorySshCredentialRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeleteRepositorySshCredentialRequest { pub operation_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub expected_revision: u64, } /// Public metadata for an explicitly pinned SSH host key. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySshHostTrust { pub host_trust_id: String, @@ -620,6 +628,7 @@ pub struct RepositorySshHostTrust { pub key_algorithm: String, pub host_key: String, pub fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub current_revision: u64, pub created_at: String, pub updated_at: String, @@ -628,6 +637,7 @@ pub struct RepositorySshHostTrust { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PutRepositorySshHostTrustRequest { pub operation_id: String, @@ -636,17 +646,22 @@ pub struct PutRepositorySshHostTrustRequest { pub port: u16, pub host_key: String, #[serde(default)] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] pub expected_revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeleteRepositorySshHostTrustRequest { pub operation_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub expected_revision: u64, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] pub enum RepositoryAccessMode { ReadOnly, @@ -654,6 +669,7 @@ pub enum RepositoryAccessMode { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySshAccessBinding { pub repository_id: String, @@ -664,14 +680,43 @@ pub struct RepositorySshAccessBinding { /// Secret-free active Repository access projection consumed by later Runtime work. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryAccessProjection { pub workspace_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] pub config_revision: u64, pub projection_digest: String, pub bindings: Vec, } +#[cfg(feature = "typescript")] +pub fn repository_access_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + RepositorySshCredential::decl(&config), + CreateRepositorySshCredentialRequest::decl(&config), + RotateRepositorySshCredentialRequest::decl(&config), + DeleteRepositorySshCredentialRequest::decl(&config), + RepositorySshHostTrust::decl(&config), + PutRepositorySshHostTrustRequest::decl(&config), + DeleteRepositorySshHostTrustRequest::decl(&config), + RepositoryAccessMode::decl(&config), + RepositorySshAccessBinding::decl(&config), + RepositoryAccessProjection::decl(&config), + ]; + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + #[cfg(test)] mod tests { use super::*; @@ -851,3 +896,50 @@ mod tests { .replace(";}", "}") } } + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_repository_access_contract_is_current() { + let expected = super::repository_access_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/repository-access-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file", + ); + } + + #[test] + fn generated_repository_access_responses_remain_secret_free() { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + for declaration in [ + super::RepositorySshCredential::decl(&config), + super::RepositorySshHostTrust::decl(&config), + super::RepositoryAccessProjection::decl(&config), + ] { + for forbidden in ["private_key", "passphrase", "secret_ref"] { + assert!( + !declaration.contains(forbidden), + "Repository Access response declaration must not expose `{forbidden}`" + ); + } + } + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect() + } +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 6164f888..dca1cad6 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 --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.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-launch.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": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.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/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts new file mode 100644 index 00000000..fa2f9d44 --- /dev/null +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -0,0 +1,79 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts + +export type RepositorySshCredential = { + credential_id: string; + workspace_id: string; + name: string; + public_key_algorithm: string; + public_key_fingerprint: string; + current_revision: number; + status: string; + created_at: string; + rotated_at: string | null; + referenced_repositories: Array; +}; + +export type CreateRepositorySshCredentialRequest = { + operation_id: string; + credential_id: string; + name: string; + private_key: string; + passphrase: string | null; +}; + +export type RotateRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; + private_key: string; + passphrase: string | null; +}; + +export type DeleteRepositorySshCredentialRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositorySshHostTrust = { + host_trust_id: string; + workspace_id: string; + hostname: string; + port: number; + key_algorithm: string; + host_key: string; + fingerprint: string; + current_revision: number; + created_at: string; + updated_at: string; + referenced_repositories: Array; +}; + +export type PutRepositorySshHostTrustRequest = { + operation_id: string; + host_trust_id: string; + hostname: string; + port: number; + host_key: string; + expected_revision: number | null; +}; + +export type DeleteRepositorySshHostTrustRequest = { + operation_id: string; + expected_revision: number; +}; + +export type RepositoryAccessMode = "read_only" | "read_write"; + +export type RepositorySshAccessBinding = { + repository_id: string; + credential_id: string; + host_trust_id: string; + access: RepositoryAccessMode; +}; + +export type RepositoryAccessProjection = { + workspace_id: string; + config_revision: number; + projection_digest: string; + bindings: Array; +}; diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts new file mode 100644 index 00000000..7908a844 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -0,0 +1,45 @@ +import { error } from "@sveltejs/kit"; +import { RepositoryAccessSchemaError } from "./repository-access.ts"; + +export async function loadRepositoryAccessJson( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, +): Promise { + let response: Response; + try { + response = await fetcher(path, { headers: { accept: "application/json" } }); + } catch { + error(503, { message: "Repository Access is temporarily unavailable." }); + } + + if (response.status === 401 || response.status === 403) { + error(403, { + message: "Repository Access is unavailable for this account.", + }); + } + if (!response.ok) { + error(502, { + message: + `Repository Access request failed with status ${response.status}.`, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + error(502, { + message: "Repository Access returned an invalid JSON response.", + }); + } + + try { + return parse(payload); + } catch (cause) { + if (cause instanceof RepositoryAccessSchemaError) { + error(502, { message: cause.message }); + } + throw cause; + } +} diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts new file mode 100644 index 00000000..0a9f0d5d --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -0,0 +1,228 @@ +import type { + RepositoryAccessProjection, + RepositorySshCredential, + RepositorySshHostTrust, +} from "../../generated/repository-access-api.ts"; + +export class RepositoryAccessSchemaError extends Error { + constructor(path: string, expected: string) { + super( + `Repository Access response schema mismatch at ${path}: expected ${expected}`, + ); + this.name = "RepositoryAccessSchemaError"; + } +} + +export function parseRepositorySshCredentials( + value: unknown, +): RepositorySshCredential[] { + return readArray(value, "credentials").map((entry, index) => + parseRepositorySshCredential(entry, `credentials[${index}]`) + ); +} + +export function parseRepositorySshCredential( + value: unknown, + path = "credential", +): RepositorySshCredential { + const record = readRecord(value, path, [ + "credential_id", + "workspace_id", + "name", + "public_key_algorithm", + "public_key_fingerprint", + "current_revision", + "status", + "created_at", + "rotated_at", + "referenced_repositories", + ]); + readString(record, "credential_id", path); + readString(record, "workspace_id", path); + readString(record, "name", path); + readString(record, "public_key_algorithm", path); + readString(record, "public_key_fingerprint", path); + readRevision(record, "current_revision", path); + readString(record, "status", path); + readString(record, "created_at", path); + readNullableString(record, "rotated_at", path); + readStringArray(record, "referenced_repositories", path); + return record as RepositorySshCredential; +} + +export function parseRepositorySshHostTrusts( + value: unknown, +): RepositorySshHostTrust[] { + return readArray(value, "host_trusts").map((entry, index) => + parseRepositorySshHostTrust(entry, `host_trusts[${index}]`) + ); +} + +export function parseRepositorySshHostTrust( + value: unknown, + path = "host_trust", +): RepositorySshHostTrust { + const record = readRecord(value, path, [ + "host_trust_id", + "workspace_id", + "hostname", + "port", + "key_algorithm", + "host_key", + "fingerprint", + "current_revision", + "created_at", + "updated_at", + "referenced_repositories", + ]); + readString(record, "host_trust_id", path); + readString(record, "workspace_id", path); + readString(record, "hostname", path); + const port = readInteger(record, "port", path); + if (port < 1 || port > 65_535) { + throw new RepositoryAccessSchemaError( + `${path}.port`, + "an integer from 1 to 65535", + ); + } + readString(record, "key_algorithm", path); + readString(record, "host_key", path); + readString(record, "fingerprint", path); + readRevision(record, "current_revision", path); + readString(record, "created_at", path); + readString(record, "updated_at", path); + readStringArray(record, "referenced_repositories", path); + return record as RepositorySshHostTrust; +} + +export function parseRepositoryAccessProjection( + value: unknown, +): RepositoryAccessProjection { + const path = "access_projection"; + const record = readRecord(value, path, [ + "workspace_id", + "config_revision", + "projection_digest", + "bindings", + ]); + readString(record, "workspace_id", path); + readRevision(record, "config_revision", path); + readString(record, "projection_digest", path); + const bindings = readArray(record.bindings, `${path}.bindings`); + bindings.forEach((binding, index) => { + const bindingPath = `${path}.bindings[${index}]`; + const bindingRecord = readRecord(binding, bindingPath, [ + "repository_id", + "credential_id", + "host_trust_id", + "access", + ]); + readString(bindingRecord, "repository_id", bindingPath); + readString(bindingRecord, "credential_id", bindingPath); + readString(bindingRecord, "host_trust_id", bindingPath); + const access = readString(bindingRecord, "access", bindingPath); + if (access !== "read_only" && access !== "read_write") { + throw new RepositoryAccessSchemaError( + `${bindingPath}.access`, + '"read_only" or "read_write"', + ); + } + }); + return record as RepositoryAccessProjection; +} + +function readRecord( + value: unknown, + path: string, + allowedKeys: readonly string[], +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new RepositoryAccessSchemaError(path, "an object"); + } + const record = value as Record; + const unknownKey = Object.keys(record).find((key) => + !allowedKeys.includes(key) + ); + if (unknownKey !== undefined) { + throw new RepositoryAccessSchemaError( + `${path}.${unknownKey}`, + "no unknown field", + ); + } + return record; +} + +function readArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) { + throw new RepositoryAccessSchemaError(path, "an array"); + } + return value; +} + +function readString( + record: Record, + key: string, + path: string, +): string { + const value = record[key]; + if (typeof value !== "string") { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string"); + } + return value; +} + +function readNullableString( + record: Record, + key: string, + path: string, +): string | null { + const value = record[key]; + if (value !== null && typeof value !== "string") { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null"); + } + return value; +} + +function readStringArray( + record: Record, + key: string, + path: string, +): string[] { + const values = readArray(record[key], `${path}.${key}`); + values.forEach((value, index) => { + if (typeof value !== "string") { + throw new RepositoryAccessSchemaError( + `${path}.${key}[${index}]`, + "a string", + ); + } + }); + return values as string[]; +} + +function readInteger( + record: Record, + key: string, + path: string, +): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a safe integer"); + } + return value; +} + +function readRevision( + record: Record, + key: string, + path: string, +): number { + const revision = readInteger(record, key, path); + if (revision < 0) { + throw new RepositoryAccessSchemaError( + `${path}.${key}`, + "a non-negative safe integer", + ); + } + return revision; +} 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 3852a6fc..f5603349 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 @@ -1,11 +1,24 @@