fix: share repository access API contracts
This commit is contained in:
@@ -16,3 +16,7 @@ workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
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());
|
||||
}
|
||||
@@ -431,6 +431,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
||||
///
|
||||
/// Secret references and secret material are deliberately not part of this DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshCredential {
|
||||
pub credential_id: String,
|
||||
@@ -438,6 +439,7 @@ pub struct RepositorySshCredential {
|
||||
pub name: String,
|
||||
pub public_key_algorithm: String,
|
||||
pub public_key_fingerprint: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub current_revision: u64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
@@ -447,6 +449,7 @@ pub struct RepositorySshCredential {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
@@ -458,9 +461,11 @@ pub struct CreateRepositorySshCredentialRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RotateRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
pub private_key: String,
|
||||
#[serde(default)]
|
||||
@@ -468,14 +473,17 @@ pub struct RotateRepositorySshCredentialRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// Public metadata for an explicitly pinned SSH host key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshHostTrust {
|
||||
pub host_trust_id: String,
|
||||
@@ -485,6 +493,7 @@ pub struct RepositorySshHostTrust {
|
||||
pub key_algorithm: String,
|
||||
pub host_key: String,
|
||||
pub fingerprint: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub current_revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
@@ -493,6 +502,7 @@ pub struct RepositorySshHostTrust {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PutRepositorySshHostTrustRequest {
|
||||
pub operation_id: String,
|
||||
@@ -501,17 +511,22 @@ pub struct PutRepositorySshHostTrustRequest {
|
||||
pub port: u16,
|
||||
pub host_key: String,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRepositorySshHostTrustRequest {
|
||||
pub operation_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryAccessMode {
|
||||
ReadOnly,
|
||||
@@ -519,6 +534,7 @@ pub enum RepositoryAccessMode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshAccessBinding {
|
||||
pub repository_id: String,
|
||||
@@ -529,14 +545,43 @@ pub struct RepositorySshAccessBinding {
|
||||
|
||||
/// Secret-free active Repository access projection consumed by later Runtime work.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryAccessProjection {
|
||||
pub workspace_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub config_revision: u64,
|
||||
pub projection_digest: String,
|
||||
pub bindings: Vec<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -561,3 +606,50 @@ mod tests {
|
||||
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
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 { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
const accessProjection = untrack(() => data.accessProjection);
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
|
||||
@@ -29,37 +42,52 @@
|
||||
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}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `request failed (${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);
|
||||
throw new Error(`Repository Access request failed with status ${response.status}.`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
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.');
|
||||
}
|
||||
return parse(payload);
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
||||
const body: CreateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-create'),
|
||||
credential_id: credentialId,
|
||||
name: credentialName,
|
||||
private_key: privateKey,
|
||||
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));
|
||||
credentialId = '';
|
||||
credentialName = '';
|
||||
@@ -77,15 +105,17 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const body: RotateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
};
|
||||
const rotated = await request<RepositorySshCredential>(
|
||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||
'POST',
|
||||
{
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
}
|
||||
body,
|
||||
parseRepositorySshCredential
|
||||
);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
rotateCredentialId = null;
|
||||
@@ -104,10 +134,16 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
||||
const body: DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-delete'),
|
||||
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);
|
||||
message = `Credential ${credential.credential_id} deleted.`;
|
||||
} catch (error) {
|
||||
@@ -121,14 +157,20 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
||||
const body: PutRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-create'),
|
||||
host_trust_id: hostTrustId,
|
||||
hostname,
|
||||
port,
|
||||
host_key: hostKey,
|
||||
expected_revision: hostExpectedRevision
|
||||
});
|
||||
};
|
||||
const created = await request<RepositorySshHostTrust>(
|
||||
'/host-trusts',
|
||||
'POST',
|
||||
body,
|
||||
parseRepositorySshHostTrust
|
||||
);
|
||||
hostTrusts = hostExpectedRevision === null
|
||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
||||
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||
@@ -158,10 +200,16 @@
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
||||
const body: DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
expected_revision: hostTrust.current_revision
|
||||
});
|
||||
};
|
||||
await request(
|
||||
`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`,
|
||||
'DELETE',
|
||||
body,
|
||||
null
|
||||
);
|
||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||
} 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>
|
||||
{#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">
|
||||
<h3>SSH credentials</h3>
|
||||
{#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 { loadJson } from "$lib/workspace/api/http";
|
||||
|
||||
export interface RepositorySshCredential {
|
||||
credential_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export interface RepositorySshHostTrust {
|
||||
host_trust_id: string;
|
||||
workspace_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const base = `/api/w/${
|
||||
encodeURIComponent(params.workspaceId)
|
||||
}/settings/repository-access`;
|
||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
||||
const workspaceId = params.workspaceId;
|
||||
const accessProjection = await loadRepositoryAccessJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||
parseRepositoryAccessProjection,
|
||||
);
|
||||
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,
|
||||
),
|
||||
]);
|
||||
if (!credentialResult.data || !hostTrustResult.data) {
|
||||
throw new Error(
|
||||
credentialResult.error ?? hostTrustResult.error ??
|
||||
"Repository access settings unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
credentials: credentialResult.data,
|
||||
hostTrusts: hostTrustResult.data,
|
||||
workspaceId,
|
||||
credentials,
|
||||
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,33 @@
|
||||
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||
|
||||
Deno.test("Repository Access loader maps missing permission to a bounded unavailable error", async () => {
|
||||
let requests = 0;
|
||||
try {
|
||||
await loadRepositoryAccessJson(
|
||||
() => {
|
||||
requests += 1;
|
||||
return Promise.resolve(new Response(null, { status: 403 }));
|
||||
},
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
(value) => value,
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = error as { status?: number; body?: { message?: string } };
|
||||
if (failure.status !== 403) {
|
||||
throw new Error(`expected bounded 403, got ${String(failure.status)}`);
|
||||
}
|
||||
if (
|
||||
failure.body?.message !==
|
||||
"Repository Access is unavailable for this account."
|
||||
) {
|
||||
throw new Error(
|
||||
`unexpected permission error: ${JSON.stringify(failure.body)}`,
|
||||
);
|
||||
}
|
||||
if (requests !== 1) {
|
||||
throw new Error(`expected one bounded request, got ${requests}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error("expected Repository Access permission error");
|
||||
});
|
||||
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
|
||||
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", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
|
||||
Reference in New Issue
Block a user