fix: share repository access API contracts

This commit is contained in:
2026-09-01 01:22:27 +09:00
parent 9756174676
commit 8b3d1302c6
11 changed files with 784 additions and 69 deletions
+4
View File
@@ -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());
}
+92
View File
@@ -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()");