chore: merge current develop into Companion DTO work
# Conflicts: # web/workspace/deno.json
This commit is contained in:
@@ -16,3 +16,7 @@ workdir.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_repository_access_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::repository_access_api_typescript());
|
||||||
|
}
|
||||||
@@ -566,6 +566,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
|||||||
///
|
///
|
||||||
/// Secret references and secret material are deliberately not part of this DTO.
|
/// Secret references and secret material are deliberately not part of this DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshCredential {
|
pub struct RepositorySshCredential {
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
@@ -573,6 +574,7 @@ pub struct RepositorySshCredential {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub public_key_algorithm: String,
|
pub public_key_algorithm: String,
|
||||||
pub public_key_fingerprint: String,
|
pub public_key_fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub current_revision: u64,
|
pub current_revision: u64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -582,6 +584,7 @@ pub struct RepositorySshCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRepositorySshCredentialRequest {
|
pub struct CreateRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
@@ -593,9 +596,11 @@ pub struct CreateRepositorySshCredentialRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RotateRepositorySshCredentialRequest {
|
pub struct RotateRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
pub private_key: String,
|
pub private_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -603,14 +608,17 @@ pub struct RotateRepositorySshCredentialRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DeleteRepositorySshCredentialRequest {
|
pub struct DeleteRepositorySshCredentialRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public metadata for an explicitly pinned SSH host key.
|
/// Public metadata for an explicitly pinned SSH host key.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshHostTrust {
|
pub struct RepositorySshHostTrust {
|
||||||
pub host_trust_id: String,
|
pub host_trust_id: String,
|
||||||
@@ -620,6 +628,7 @@ pub struct RepositorySshHostTrust {
|
|||||||
pub key_algorithm: String,
|
pub key_algorithm: String,
|
||||||
pub host_key: String,
|
pub host_key: String,
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub current_revision: u64,
|
pub current_revision: u64,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -628,6 +637,7 @@ pub struct RepositorySshHostTrust {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PutRepositorySshHostTrustRequest {
|
pub struct PutRepositorySshHostTrustRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
@@ -636,17 +646,22 @@ pub struct PutRepositorySshHostTrustRequest {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub host_key: String,
|
pub host_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
pub expected_revision: Option<u64>,
|
pub expected_revision: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct DeleteRepositorySshHostTrustRequest {
|
pub struct DeleteRepositorySshHostTrustRequest {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RepositoryAccessMode {
|
pub enum RepositoryAccessMode {
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
@@ -654,6 +669,7 @@ pub enum RepositoryAccessMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositorySshAccessBinding {
|
pub struct RepositorySshAccessBinding {
|
||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
@@ -664,14 +680,43 @@ pub struct RepositorySshAccessBinding {
|
|||||||
|
|
||||||
/// Secret-free active Repository access projection consumed by later Runtime work.
|
/// Secret-free active Repository access projection consumed by later Runtime work.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RepositoryAccessProjection {
|
pub struct RepositoryAccessProjection {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
pub projection_digest: String,
|
pub projection_digest: String,
|
||||||
pub bindings: Vec<RepositorySshAccessBinding>,
|
pub bindings: Vec<RepositorySshAccessBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -851,3 +896,50 @@ mod tests {
|
|||||||
.replace(";}", "}")
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<RepositorySshAccessBinding>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { RepositoryAccessSchemaError } from "./repository-access.ts";
|
||||||
|
|
||||||
|
export async function loadRepositoryAccessJson<T>(
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
path: string,
|
||||||
|
parse: (value: unknown) => T,
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new RepositoryAccessSchemaError(path, "an object");
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import type {
|
||||||
|
CreateRepositorySshCredentialRequest,
|
||||||
|
DeleteRepositorySshCredentialRequest,
|
||||||
|
DeleteRepositorySshHostTrustRequest,
|
||||||
|
PutRepositorySshHostTrustRequest,
|
||||||
|
RepositorySshCredential,
|
||||||
|
RepositorySshHostTrust,
|
||||||
|
RotateRepositorySshCredentialRequest,
|
||||||
|
} from '$lib/generated/repository-access-api';
|
||||||
|
import {
|
||||||
|
parseRepositorySshCredential,
|
||||||
|
parseRepositorySshHostTrust,
|
||||||
|
} from '$lib/workspace/api/repository-access';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||||
|
const accessProjection = untrack(() => data.accessProjection);
|
||||||
let message = $state<string | null>(null);
|
let message = $state<string | null>(null);
|
||||||
let pending = $state(false);
|
let pending = $state(false);
|
||||||
|
|
||||||
@@ -29,37 +42,52 @@
|
|||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
|
function request<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body: unknown,
|
||||||
|
parse: (value: unknown) => T
|
||||||
|
): Promise<T>;
|
||||||
|
function request(path: string, method: string, body: unknown, parse: null): Promise<void>;
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
body: unknown,
|
||||||
|
parse: ((value: unknown) => T) | null
|
||||||
|
): Promise<T | undefined> {
|
||||||
const response = await fetch(`${base}${path}`, {
|
const response = await fetch(`${base}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = `request failed (${response.status})`;
|
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||||
try {
|
|
||||||
const payload = (await response.json()) as { error?: string; message?: string };
|
|
||||||
detail = payload.message ?? payload.error ?? detail;
|
|
||||||
} catch {
|
|
||||||
// Do not surface submitted secret values from response bodies.
|
|
||||||
}
|
}
|
||||||
throw new Error(detail);
|
if (response.status === 204) return undefined;
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (parse === null) {
|
||||||
|
throw new Error('Repository Access returned an unexpected response body.');
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
return parse(payload);
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCredential() {
|
async function createCredential() {
|
||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
const body: CreateRepositorySshCredentialRequest = {
|
||||||
operation_id: operationId('credential-create'),
|
operation_id: operationId('credential-create'),
|
||||||
credential_id: credentialId,
|
credential_id: credentialId,
|
||||||
name: credentialName,
|
name: credentialName,
|
||||||
private_key: privateKey,
|
private_key: privateKey,
|
||||||
passphrase: passphrase || null
|
passphrase: passphrase || null
|
||||||
});
|
};
|
||||||
|
const created = await request<RepositorySshCredential>(
|
||||||
|
'/credentials',
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshCredential
|
||||||
|
);
|
||||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||||
credentialId = '';
|
credentialId = '';
|
||||||
credentialName = '';
|
credentialName = '';
|
||||||
@@ -77,15 +105,17 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const rotated = await request<RepositorySshCredential>(
|
const body: RotateRepositorySshCredentialRequest = {
|
||||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
operation_id: operationId('credential-rotate'),
|
operation_id: operationId('credential-rotate'),
|
||||||
expected_revision: credential.current_revision,
|
expected_revision: credential.current_revision,
|
||||||
private_key: rotatePrivateKey,
|
private_key: rotatePrivateKey,
|
||||||
passphrase: rotatePassphrase || null
|
passphrase: rotatePassphrase || null
|
||||||
}
|
};
|
||||||
|
const rotated = await request<RepositorySshCredential>(
|
||||||
|
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshCredential
|
||||||
);
|
);
|
||||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||||
rotateCredentialId = null;
|
rotateCredentialId = null;
|
||||||
@@ -104,10 +134,16 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
const body: DeleteRepositorySshCredentialRequest = {
|
||||||
operation_id: operationId('credential-delete'),
|
operation_id: operationId('credential-delete'),
|
||||||
expected_revision: credential.current_revision
|
expected_revision: credential.current_revision
|
||||||
});
|
};
|
||||||
|
await request(
|
||||||
|
`/credentials/${encodeURIComponent(credential.credential_id)}`,
|
||||||
|
'DELETE',
|
||||||
|
body,
|
||||||
|
null
|
||||||
|
);
|
||||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||||
message = `Credential ${credential.credential_id} deleted.`;
|
message = `Credential ${credential.credential_id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,14 +157,20 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
const body: PutRepositorySshHostTrustRequest = {
|
||||||
operation_id: operationId('host-trust-create'),
|
operation_id: operationId('host-trust-create'),
|
||||||
host_trust_id: hostTrustId,
|
host_trust_id: hostTrustId,
|
||||||
hostname,
|
hostname,
|
||||||
port,
|
port,
|
||||||
host_key: hostKey,
|
host_key: hostKey,
|
||||||
expected_revision: hostExpectedRevision
|
expected_revision: hostExpectedRevision
|
||||||
});
|
};
|
||||||
|
const created = await request<RepositorySshHostTrust>(
|
||||||
|
'/host-trusts',
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
parseRepositorySshHostTrust
|
||||||
|
);
|
||||||
hostTrusts = hostExpectedRevision === null
|
hostTrusts = hostExpectedRevision === null
|
||||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
? [...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);
|
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||||
@@ -158,10 +200,16 @@
|
|||||||
pending = true;
|
pending = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
const body: DeleteRepositorySshHostTrustRequest = {
|
||||||
operation_id: operationId('host-trust-delete'),
|
operation_id: operationId('host-trust-delete'),
|
||||||
expected_revision: hostTrust.current_revision
|
expected_revision: hostTrust.current_revision
|
||||||
});
|
};
|
||||||
|
await request(
|
||||||
|
`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`,
|
||||||
|
'DELETE',
|
||||||
|
body,
|
||||||
|
null
|
||||||
|
);
|
||||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,6 +230,18 @@
|
|||||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||||
{#if message}<p class="status-message">{message}</p>{/if}
|
{#if message}<p class="status-message">{message}</p>{/if}
|
||||||
|
|
||||||
|
<div class="settings-runtime-list">
|
||||||
|
<h3>Active access projection</h3>
|
||||||
|
<p>Config revision {accessProjection.config_revision} · <code>{accessProjection.projection_digest}</code></p>
|
||||||
|
{#if accessProjection.bindings.length === 0}<p>No repository access bindings are active.</p>{/if}
|
||||||
|
{#each accessProjection.bindings as binding (binding.repository_id)}
|
||||||
|
<div class="card">
|
||||||
|
<strong>{binding.repository_id}</strong>
|
||||||
|
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-runtime-list">
|
<div class="settings-runtime-list">
|
||||||
<h3>SSH credentials</h3>
|
<h3>SSH credentials</h3>
|
||||||
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
||||||
|
|||||||
@@ -1,50 +1,36 @@
|
|||||||
|
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
parseRepositoryAccessProjection,
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
} from "$lib/workspace/api/repository-access";
|
||||||
|
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import { loadJson } from "$lib/workspace/api/http";
|
|
||||||
|
|
||||||
export interface RepositorySshCredential {
|
|
||||||
credential_id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
name: string;
|
|
||||||
public_key_algorithm: string;
|
|
||||||
public_key_fingerprint: string;
|
|
||||||
current_revision: number;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
rotated_at: string | null;
|
|
||||||
referenced_repositories: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepositorySshHostTrust {
|
|
||||||
host_trust_id: string;
|
|
||||||
workspace_id: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
key_algorithm: string;
|
|
||||||
host_key: string;
|
|
||||||
fingerprint: string;
|
|
||||||
current_revision: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
referenced_repositories: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const base = `/api/w/${
|
const workspaceId = params.workspaceId;
|
||||||
encodeURIComponent(params.workspaceId)
|
const accessProjection = await loadRepositoryAccessJson(
|
||||||
}/settings/repository-access`;
|
fetch,
|
||||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
parseRepositoryAccessProjection,
|
||||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
|
||||||
]);
|
|
||||||
if (!credentialResult.data || !hostTrustResult.data) {
|
|
||||||
throw new Error(
|
|
||||||
credentialResult.error ?? hostTrustResult.error ??
|
|
||||||
"Repository access settings unavailable",
|
|
||||||
);
|
);
|
||||||
}
|
const [credentials, hostTrusts] = await Promise.all([
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/settings/repository-access/credentials"),
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
),
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"),
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId,
|
||||||
credentials: credentialResult.data,
|
credentials,
|
||||||
hostTrusts: hostTrustResult.data,
|
hostTrusts,
|
||||||
|
accessProjection,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
parseRepositoryAccessProjection,
|
||||||
|
parseRepositorySshCredentials,
|
||||||
|
parseRepositorySshHostTrusts,
|
||||||
|
RepositoryAccessSchemaError,
|
||||||
|
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSchemaError(body: () => unknown, path: string): void {
|
||||||
|
try {
|
||||||
|
body();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof RepositoryAccessSchemaError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!error.message.includes(path)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected schema error path ${path}, got ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`expected RepositoryAccessSchemaError for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = {
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
name: "Deploy key",
|
||||||
|
public_key_algorithm: "ssh-ed25519",
|
||||||
|
public_key_fingerprint: "SHA256:credential",
|
||||||
|
current_revision: 2,
|
||||||
|
status: "active",
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
rotated_at: null,
|
||||||
|
referenced_repositories: ["main"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostTrust = {
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
hostname: "gitea.example.test",
|
||||||
|
port: 22,
|
||||||
|
key_algorithm: "ssh-ed25519",
|
||||||
|
host_key: "ssh-ed25519 AAAA",
|
||||||
|
fingerprint: "SHA256:host",
|
||||||
|
current_revision: 3,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-02T00:00:00Z",
|
||||||
|
referenced_repositories: ["main"],
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||||
|
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||||
|
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||||
|
assertEquals(
|
||||||
|
parseRepositoryAccessProjection({
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "read_only",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "read_only",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject malformed list responses", () => {
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshCredentials({ credentials: [credential] }),
|
||||||
|
"credentials",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshHostTrusts({ host_trusts: [hostTrust] }),
|
||||||
|
"host_trusts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
|
||||||
|
const { current_revision: _revision, ...missingRevision } = credential;
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshCredentials([missingRevision]),
|
||||||
|
"credentials[0].current_revision",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
|
||||||
|
"host_trusts[0].port",
|
||||||
|
);
|
||||||
|
assertSchemaError(
|
||||||
|
() =>
|
||||||
|
parseRepositoryAccessProjection({
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
config_revision: 4,
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
bindings: [{
|
||||||
|
repository_id: "main",
|
||||||
|
credential_id: "deploy-key",
|
||||||
|
host_trust_id: "gitea",
|
||||||
|
access: "admin",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
"access_projection.bindings[0].access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access parsers reject unknown response fields", () => {
|
||||||
|
assertSchemaError(
|
||||||
|
() =>
|
||||||
|
parseRepositorySshCredentials([{ ...credential, private_key: "secret" }]),
|
||||||
|
"credentials[0].private_key",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { loadRepositoryAccessJson } 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 } };
|
||||||
|
|
||||||
|
async function captureHttpFailure(
|
||||||
|
run: () => Promise<unknown>,
|
||||||
|
expectedStatus: number,
|
||||||
|
expectedMessage: string,
|
||||||
|
): Promise<HttpFailure> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as HttpFailure;
|
||||||
|
if (failure.status !== expectedStatus) {
|
||||||
|
throw new Error(
|
||||||
|
`expected bounded ${expectedStatus}, got ${String(failure.status)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failure.body?.message !== expectedMessage) {
|
||||||
|
throw new Error(
|
||||||
|
`unexpected bounded error: ${JSON.stringify(failure.body)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
throw new Error(`expected bounded ${expectedStatus} error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of [401, 403]) {
|
||||||
|
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() => {
|
||||||
|
requests += 1;
|
||||||
|
return Promise.resolve(new Response(null, { status }));
|
||||||
|
},
|
||||||
|
"/api/w/workspace-1/settings/repository-access",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
403,
|
||||||
|
"Repository Access is unavailable for this account.",
|
||||||
|
);
|
||||||
|
if (requests !== 1) {
|
||||||
|
throw new Error(`expected one bounded request, got ${requests}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader maps invalid JSON to safe bounded 502", async () => {
|
||||||
|
const upstreamSecret = "private-key-must-not-leak";
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(upstreamSecret, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access returned an invalid JSON response.",
|
||||||
|
);
|
||||||
|
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||||
|
throw new Error("invalid JSON error exposed upstream response content");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502", async () => {
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() => Promise.resolve(Response.json({ stale: true })),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||||
|
() => {
|
||||||
|
throw new RepositoryAccessSchemaError("credentials", "an array");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access response schema mismatch at credentials: expected an array",
|
||||||
|
);
|
||||||
|
if (!failure.body?.message?.includes("credentials")) {
|
||||||
|
throw new Error("schema mismatch error omitted the failing response path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
|
||||||
|
const upstreamSecret = "secret-ref-must-not-leak";
|
||||||
|
const failure = await captureHttpFailure(
|
||||||
|
() =>
|
||||||
|
loadRepositoryAccessJson(
|
||||||
|
() =>
|
||||||
|
Promise.resolve(
|
||||||
|
Response.json(
|
||||||
|
{ message: upstreamSecret, secret_ref: upstreamSecret },
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"/api/w/workspace-1/settings/repository-access/host-trusts",
|
||||||
|
(value) => value,
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
"Repository Access request failed with status 500.",
|
||||||
|
);
|
||||||
|
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||||
|
throw new Error("bounded upstream error exposed response content");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
|
|||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const loaderSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
||||||
|
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("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 duplicate of [
|
||||||
|
"interface RepositorySshCredential",
|
||||||
|
"interface RepositorySshHostTrust",
|
||||||
|
"interface RepositoryAccessProjection",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
!loaderSource.includes(duplicate) && !source.includes(duplicate),
|
||||||
|
`Web code must not redeclare ${duplicate}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Repository Access renders the shared access projection fields", () => {
|
||||||
|
for (
|
||||||
|
const field of [
|
||||||
|
"accessProjection.config_revision",
|
||||||
|
"accessProjection.projection_digest",
|
||||||
|
"accessProjection.bindings",
|
||||||
|
"binding.repository_id",
|
||||||
|
"binding.credential_id",
|
||||||
|
"binding.host_trust_id",
|
||||||
|
"binding.access",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(source.includes(field), `missing access projection field ${field}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||||
const createStart = source.indexOf("async function createCredential()");
|
const createStart = source.indexOf("async function createCredential()");
|
||||||
|
|||||||
Reference in New Issue
Block a user