fix: share repository access API contracts
This commit is contained in:
@@ -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