chore: merge current develop into Companion DTO work

# Conflicts:
#	web/workspace/deno.json
This commit is contained in:
2026-09-01 01:59:39 +09:00
12 changed files with 866 additions and 70 deletions
+4
View File
@@ -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());
}
+92
View File
@@ -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()
}
}
+1 -1
View File
@@ -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()");