fix: validate workspace repository browser payloads
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.
|
||||
// Do not edit this file directly.
|
||||
|
||||
export type WorkspaceSummary = {
|
||||
workspace_id: string;
|
||||
owner_account_id: string | null;
|
||||
display_name: string;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
provider: string | null;
|
||||
source: RepositorySource;
|
||||
default_ref: string | null;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: RepositoryObservedStatus;
|
||||
observed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCreateResponse = {
|
||||
workspace: WorkspaceSummary;
|
||||
repository: WorkspaceRepositoryRecord;
|
||||
config_revision: number;
|
||||
request_fingerprint: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceAuthConfig = {
|
||||
"Passkey": {
|
||||
rp_id: string;
|
||||
origin: string;
|
||||
public_base_url: string;
|
||||
cookie_name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkspacePermissionSummary = {
|
||||
manage_repositories: boolean;
|
||||
manage_secrets: boolean;
|
||||
};
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WorkspaceExtensionPointState = {
|
||||
status: string;
|
||||
note: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type WorkspaceExtensionPoints = {
|
||||
store: string;
|
||||
event_stream: WorkspaceExtensionPointState;
|
||||
host_worker_bridge: WorkspaceExtensionPointState;
|
||||
companion_console: WorkspaceExtensionPointState;
|
||||
};
|
||||
|
||||
export type WorkspaceResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
record_authority: string;
|
||||
schema_version: number;
|
||||
auth: WorkspaceAuthConfig;
|
||||
permissions: WorkspacePermissionSummary;
|
||||
extension_points: WorkspaceExtensionPoints;
|
||||
};
|
||||
|
||||
export type RepositorySourceKind =
|
||||
| "local_path"
|
||||
| "file"
|
||||
| "ssh"
|
||||
| "http"
|
||||
| "https"
|
||||
| "invalid";
|
||||
|
||||
export type RepositorySource = {
|
||||
kind: RepositorySourceKind;
|
||||
/**
|
||||
* Canonical source representation. This is an absolute local path for
|
||||
* `local_path`, and a normalized URI/remote specification otherwise.
|
||||
*/
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
|
||||
|
||||
export type RepositoryDiagnostic = {
|
||||
severity: string;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type GitRemoteSummary = { name: string; fetch_url: string };
|
||||
|
||||
export type GitRepositorySummary = {
|
||||
status: string;
|
||||
head: string | null;
|
||||
branch: string | null;
|
||||
dirty: boolean;
|
||||
remotes: Array<GitRemoteSummary>;
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
source: RepositorySource;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: RepositoryObservedStatus;
|
||||
observed_at?: string | null;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git?: GitRepositorySummary | null;
|
||||
diagnostics?: Array<RepositoryDiagnostic> | null;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
parents: Array<string>;
|
||||
refs: Array<string>;
|
||||
};
|
||||
|
||||
export type RepositoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: Array<RepositorySummary>;
|
||||
source: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RepositoryDetailResponse = {
|
||||
workspace_id: string;
|
||||
item: RepositorySummary;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
default_selector?: string | null;
|
||||
limit: number;
|
||||
items: Array<GitCommitSummary>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
@@ -1,41 +1,18 @@
|
||||
export type WorkspaceCatalogRecord = {
|
||||
workspace_id: string;
|
||||
owner_account_id: string | null;
|
||||
display_name: string;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RepositorySourceKind =
|
||||
| "local_path"
|
||||
| "file"
|
||||
| "ssh"
|
||||
| "http"
|
||||
| "https"
|
||||
| "invalid";
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
provider: string | null;
|
||||
source: {
|
||||
kind: RepositorySourceKind;
|
||||
uri: string;
|
||||
};
|
||||
default_ref: string | null;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at: string | null;
|
||||
};
|
||||
import {
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceCatalogResponse,
|
||||
parseWorkspaceCreateResponse,
|
||||
type RepositorySummary,
|
||||
type WorkspaceCreateResponse,
|
||||
type WorkspaceSummary,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
|
||||
export type WorkspaceCatalogRecord = WorkspaceSummary;
|
||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||
repositories: WorkspaceRepositoryRecord[];
|
||||
repositories: RepositorySummary[];
|
||||
repository_error?: string;
|
||||
};
|
||||
export type CreateWorkspaceResponse = WorkspaceCreateResponse;
|
||||
|
||||
export type CreateWorkspaceRequest = {
|
||||
operation_key: string;
|
||||
@@ -47,14 +24,6 @@ export type CreateWorkspaceRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateWorkspaceResponse = {
|
||||
workspace: WorkspaceCatalogRecord;
|
||||
repository: WorkspaceRepositoryRecord;
|
||||
config_revision: number;
|
||||
request_fingerprint: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export class WorkspaceCatalogError extends Error {
|
||||
constructor(
|
||||
public readonly status: number | null,
|
||||
@@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch;
|
||||
export async function listWorkspaces(
|
||||
fetcher: Fetch,
|
||||
): Promise<WorkspaceCatalogRecord[]> {
|
||||
return await fetchJson<WorkspaceCatalogRecord[]>(
|
||||
fetcher,
|
||||
"/api/workspaces?limit=200",
|
||||
return parseWorkspaceCatalogResponse(
|
||||
await fetchJson(fetcher, "/api/workspaces?limit=200"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWorkspaceRepositories(
|
||||
fetcher: Fetch,
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceRepositoryRecord[]> {
|
||||
return await fetchJson<WorkspaceRepositoryRecord[]>(
|
||||
): Promise<RepositorySummary[]> {
|
||||
return parseRepositoryListResponse(
|
||||
await fetchJson(
|
||||
fetcher,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||
);
|
||||
),
|
||||
).items;
|
||||
}
|
||||
|
||||
export async function loadWorkspaceCatalog(
|
||||
@@ -115,11 +85,13 @@ export async function createWorkspace(
|
||||
fetcher: Fetch,
|
||||
request: CreateWorkspaceRequest,
|
||||
): Promise<CreateWorkspaceResponse> {
|
||||
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
|
||||
return parseWorkspaceCreateResponse(
|
||||
await fetchJson(fetcher, "/api/workspaces", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function creationErrorMessage(error: unknown): string {
|
||||
@@ -150,11 +122,11 @@ export function createOperationKey(): string {
|
||||
}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
async function fetchJson(
|
||||
fetcher: Fetch,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(input, init);
|
||||
@@ -172,7 +144,7 @@ async function fetchJson<T>(
|
||||
}
|
||||
throw new WorkspaceCatalogError(response.status, detail);
|
||||
}
|
||||
return await response.json() as T;
|
||||
return await response.json() as unknown;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import type {
|
||||
Diagnostic,
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryDiagnostic,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySummary,
|
||||
WorkspaceAuthConfig,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspaceExtensionPoints,
|
||||
WorkspaceExtensionPointState,
|
||||
WorkspacePermissionSummary,
|
||||
WorkspaceRepositoryRecord,
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
|
||||
export type {
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySummary,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspacePermissionSummary,
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const SOURCE_KINDS = new Set<RepositorySourceKind>([
|
||||
"local_path",
|
||||
"file",
|
||||
"ssh",
|
||||
"http",
|
||||
"https",
|
||||
"invalid",
|
||||
]);
|
||||
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
|
||||
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
|
||||
|
||||
function object(value: unknown, path: string): JsonObject {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} must be an object`);
|
||||
}
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, path: string): string {
|
||||
if (typeof value !== "string") throw new Error(`${path} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
||||
throw new Error(`${path} must be a safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, path: string): string | null {
|
||||
return value === null ? null : string(value, path);
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): string | null | undefined {
|
||||
return value === undefined ? undefined : nullableString(value, path);
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: JsonObject,
|
||||
keys: readonly string[],
|
||||
path: string,
|
||||
): void {
|
||||
const allowed = new Set(keys);
|
||||
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unexpected) {
|
||||
throw new Error(`${path}.${unexpected} is not part of the wire contract`);
|
||||
}
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown, path: string): Diagnostic {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["code", "severity", "message"], path);
|
||||
const severity = string(item.severity, `${path}.severity`);
|
||||
if (!DIAGNOSTIC_SEVERITIES.has(severity)) {
|
||||
throw new Error(`${path}.severity is invalid`);
|
||||
}
|
||||
return {
|
||||
code: string(item.code, `${path}.code`),
|
||||
severity: severity as Diagnostic["severity"],
|
||||
message: string(item.message, `${path}.message`),
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryDiagnostic(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): RepositoryDiagnostic {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["severity", "code", "message"], path);
|
||||
return {
|
||||
severity: string(item.severity, `${path}.severity`),
|
||||
code: string(item.code, `${path}.code`),
|
||||
message: string(item.message, `${path}.message`),
|
||||
};
|
||||
}
|
||||
|
||||
function repositorySource(value: unknown, path: string): RepositorySource {
|
||||
const source = object(value, path);
|
||||
exactKeys(source, ["kind", "uri"], path);
|
||||
const kind = string(source.kind, `${path}.kind`);
|
||||
if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
|
||||
throw new Error(`${path}.kind is invalid`);
|
||||
}
|
||||
return {
|
||||
kind: kind as RepositorySourceKind,
|
||||
uri: string(source.uri, `${path}.uri`),
|
||||
};
|
||||
}
|
||||
|
||||
function gitRemote(value: unknown, path: string): GitRemoteSummary {
|
||||
const remote = object(value, path);
|
||||
exactKeys(remote, ["name", "fetch_url"], path);
|
||||
return {
|
||||
name: string(remote.name, `${path}.name`),
|
||||
fetch_url: string(remote.fetch_url, `${path}.fetch_url`),
|
||||
};
|
||||
}
|
||||
|
||||
function gitSummary(value: unknown, path: string): GitRepositorySummary {
|
||||
const git = object(value, path);
|
||||
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
|
||||
return {
|
||||
status: string(git.status, `${path}.status`),
|
||||
head: nullableString(git.head, `${path}.head`),
|
||||
branch: nullableString(git.branch, `${path}.branch`),
|
||||
dirty: boolean(git.dirty, `${path}.dirty`),
|
||||
remotes: array(git.remotes, `${path}.remotes`).map((item, index) =>
|
||||
gitRemote(item, `${path}.remotes[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"id",
|
||||
"display_name",
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"observed_at",
|
||||
"default_selector",
|
||||
"record_authority",
|
||||
"git",
|
||||
"diagnostics",
|
||||
],
|
||||
path,
|
||||
);
|
||||
const observedStatus = string(
|
||||
item.observed_status,
|
||||
`${path}.observed_status`,
|
||||
);
|
||||
if (!OBSERVED_STATUSES.has(observedStatus)) {
|
||||
throw new Error(`${path}.observed_status is invalid`);
|
||||
}
|
||||
const diagnostics =
|
||||
item.diagnostics === undefined || item.diagnostics === null
|
||||
? item.diagnostics
|
||||
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) =>
|
||||
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
|
||||
);
|
||||
return {
|
||||
id: string(item.id, `${path}.id`),
|
||||
display_name: string(item.display_name, `${path}.display_name`),
|
||||
kind: string(item.kind, `${path}.kind`),
|
||||
provider: string(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
source_revision: integer(item.source_revision, `${path}.source_revision`),
|
||||
source_fingerprint: string(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
),
|
||||
observed_status: observedStatus as RepositorySummary["observed_status"],
|
||||
observed_at: optionalNullableString(
|
||||
item.observed_at,
|
||||
`${path}.observed_at`,
|
||||
),
|
||||
default_selector: optionalNullableString(
|
||||
item.default_selector,
|
||||
`${path}.default_selector`,
|
||||
),
|
||||
record_authority: string(item.record_authority, `${path}.record_authority`),
|
||||
git: item.git === undefined || item.git === null
|
||||
? item.git
|
||||
: gitSummary(item.git, `${path}.git`),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceSummary(value: unknown, path: string): WorkspaceSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"workspace_id",
|
||||
"owner_account_id",
|
||||
"display_name",
|
||||
"state",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
|
||||
owner_account_id: nullableString(
|
||||
item.owner_account_id,
|
||||
`${path}.owner_account_id`,
|
||||
),
|
||||
display_name: string(item.display_name, `${path}.display_name`),
|
||||
state: string(item.state, `${path}.state`),
|
||||
created_at: string(item.created_at, `${path}.created_at`),
|
||||
updated_at: string(item.updated_at, `${path}.updated_at`),
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceRepositoryRecord(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): WorkspaceRepositoryRecord {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"workspace_id",
|
||||
"repository_id",
|
||||
"name",
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
"default_ref",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"observed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
path,
|
||||
);
|
||||
const observedStatus = string(
|
||||
item.observed_status,
|
||||
`${path}.observed_status`,
|
||||
);
|
||||
if (!OBSERVED_STATUSES.has(observedStatus)) {
|
||||
throw new Error(`${path}.observed_status is invalid`);
|
||||
}
|
||||
return {
|
||||
workspace_id: string(item.workspace_id, `${path}.workspace_id`),
|
||||
repository_id: string(item.repository_id, `${path}.repository_id`),
|
||||
name: string(item.name, `${path}.name`),
|
||||
kind: string(item.kind, `${path}.kind`),
|
||||
provider: nullableString(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
default_ref: nullableString(item.default_ref, `${path}.default_ref`),
|
||||
source_revision: integer(item.source_revision, `${path}.source_revision`),
|
||||
source_fingerprint: string(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
),
|
||||
observed_status:
|
||||
observedStatus as WorkspaceRepositoryRecord["observed_status"],
|
||||
observed_at: nullableString(item.observed_at, `${path}.observed_at`),
|
||||
created_at: string(item.created_at, `${path}.created_at`),
|
||||
updated_at: string(item.updated_at, `${path}.updated_at`),
|
||||
};
|
||||
}
|
||||
|
||||
function extensionPoint(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): WorkspaceExtensionPointState {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["status", "note", "diagnostics"], path);
|
||||
return {
|
||||
status: string(item.status, `${path}.status`),
|
||||
note: string(item.note, `${path}.note`),
|
||||
diagnostics: array(item.diagnostics, `${path}.diagnostics`).map((
|
||||
entry,
|
||||
index,
|
||||
) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
function extensionPoints(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): WorkspaceExtensionPoints {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, [
|
||||
"store",
|
||||
"event_stream",
|
||||
"host_worker_bridge",
|
||||
"companion_console",
|
||||
], path);
|
||||
return {
|
||||
store: string(item.store, `${path}.store`),
|
||||
event_stream: extensionPoint(item.event_stream, `${path}.event_stream`),
|
||||
host_worker_bridge: extensionPoint(
|
||||
item.host_worker_bridge,
|
||||
`${path}.host_worker_bridge`,
|
||||
),
|
||||
companion_console: extensionPoint(
|
||||
item.companion_console,
|
||||
`${path}.companion_console`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
|
||||
const auth = object(value, path);
|
||||
exactKeys(auth, ["Passkey"], path);
|
||||
const passkey = object(auth.Passkey, `${path}.Passkey`);
|
||||
exactKeys(
|
||||
passkey,
|
||||
["rp_id", "origin", "public_base_url", "cookie_name"],
|
||||
`${path}.Passkey`,
|
||||
);
|
||||
return {
|
||||
Passkey: {
|
||||
rp_id: string(passkey.rp_id, `${path}.Passkey.rp_id`),
|
||||
origin: string(passkey.origin, `${path}.Passkey.origin`),
|
||||
public_base_url: string(
|
||||
passkey.public_base_url,
|
||||
`${path}.Passkey.public_base_url`,
|
||||
),
|
||||
cookie_name: string(passkey.cookie_name, `${path}.Passkey.cookie_name`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
|
||||
return {
|
||||
manage_repositories: boolean(
|
||||
item.manage_repositories,
|
||||
`${path}.manage_repositories`,
|
||||
),
|
||||
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
||||
};
|
||||
}
|
||||
|
||||
function commitSummary(value: unknown, path: string): GitCommitSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"hash",
|
||||
"short_hash",
|
||||
"summary",
|
||||
"author_name",
|
||||
"author_email",
|
||||
"author_date",
|
||||
"parents",
|
||||
"refs",
|
||||
],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
hash: string(item.hash, `${path}.hash`),
|
||||
short_hash: string(item.short_hash, `${path}.short_hash`),
|
||||
summary: string(item.summary, `${path}.summary`),
|
||||
author_name: string(item.author_name, `${path}.author_name`),
|
||||
author_email: string(item.author_email, `${path}.author_email`),
|
||||
author_date: string(item.author_date, `${path}.author_date`),
|
||||
parents: array(item.parents, `${path}.parents`).map((entry, index) =>
|
||||
string(entry, `${path}.parents[${index}]`)
|
||||
),
|
||||
refs: array(item.refs, `${path}.refs`).map((entry, index) =>
|
||||
string(entry, `${path}.refs[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceCatalogResponse(
|
||||
value: unknown,
|
||||
): WorkspaceSummary[] {
|
||||
return array(value, "workspaces").map((item, index) =>
|
||||
workspaceSummary(item, `workspaces[${index}]`)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseWorkspaceCreateResponse(
|
||||
value: unknown,
|
||||
): WorkspaceCreateResponse {
|
||||
const response = object(value, "workspace create response");
|
||||
exactKeys(
|
||||
response,
|
||||
[
|
||||
"workspace",
|
||||
"repository",
|
||||
"config_revision",
|
||||
"request_fingerprint",
|
||||
"replayed",
|
||||
],
|
||||
"workspace create response",
|
||||
);
|
||||
return {
|
||||
workspace: workspaceSummary(
|
||||
response.workspace,
|
||||
"workspace create response.workspace",
|
||||
),
|
||||
repository: workspaceRepositoryRecord(
|
||||
response.repository,
|
||||
"workspace create response.repository",
|
||||
),
|
||||
config_revision: integer(
|
||||
response.config_revision,
|
||||
"workspace create response.config_revision",
|
||||
),
|
||||
request_fingerprint: string(
|
||||
response.request_fingerprint,
|
||||
"workspace create response.request_fingerprint",
|
||||
),
|
||||
replayed: boolean(response.replayed, "workspace create response.replayed"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceResponse(value: unknown): WorkspaceResponse {
|
||||
const response = object(value, "workspace response");
|
||||
exactKeys(
|
||||
response,
|
||||
[
|
||||
"workspace_id",
|
||||
"display_name",
|
||||
"record_authority",
|
||||
"schema_version",
|
||||
"auth",
|
||||
"permissions",
|
||||
"extension_points",
|
||||
],
|
||||
"workspace response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
response.workspace_id,
|
||||
"workspace response.workspace_id",
|
||||
),
|
||||
display_name: string(
|
||||
response.display_name,
|
||||
"workspace response.display_name",
|
||||
),
|
||||
record_authority: string(
|
||||
response.record_authority,
|
||||
"workspace response.record_authority",
|
||||
),
|
||||
schema_version: integer(
|
||||
response.schema_version,
|
||||
"workspace response.schema_version",
|
||||
),
|
||||
auth: authConfig(response.auth, "workspace response.auth"),
|
||||
permissions: permissions(
|
||||
response.permissions,
|
||||
"workspace response.permissions",
|
||||
),
|
||||
extension_points: extensionPoints(
|
||||
response.extension_points,
|
||||
"workspace response.extension_points",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRepositoryListResponse(
|
||||
value: unknown,
|
||||
): RepositoryListResponse {
|
||||
const response = object(value, "repository list response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "items", "source", "diagnostics"],
|
||||
"repository list response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
response.workspace_id,
|
||||
"repository list response.workspace_id",
|
||||
),
|
||||
items: array(response.items, "repository list response.items").map((
|
||||
item,
|
||||
index,
|
||||
) => repositorySummary(item, `repository list response.items[${index}]`)),
|
||||
source: string(response.source, "repository list response.source"),
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"repository list response.diagnostics",
|
||||
).map(
|
||||
(item, index) =>
|
||||
diagnostic(item, `repository list response.diagnostics[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRepositoryDetailResponse(
|
||||
value: unknown,
|
||||
): RepositoryDetailResponse {
|
||||
const response = object(value, "repository detail response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "item", "source"],
|
||||
"repository detail response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
response.workspace_id,
|
||||
"repository detail response.workspace_id",
|
||||
),
|
||||
item: repositorySummary(response.item, "repository detail response.item"),
|
||||
source: string(response.source, "repository detail response.source"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRepositoryLogResponse(
|
||||
value: unknown,
|
||||
): RepositoryLogResponse {
|
||||
const response = object(value, "repository log response");
|
||||
exactKeys(
|
||||
response,
|
||||
[
|
||||
"workspace_id",
|
||||
"repository_id",
|
||||
"default_selector",
|
||||
"limit",
|
||||
"items",
|
||||
"diagnostics",
|
||||
],
|
||||
"repository log response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
response.workspace_id,
|
||||
"repository log response.workspace_id",
|
||||
),
|
||||
repository_id: string(
|
||||
response.repository_id,
|
||||
"repository log response.repository_id",
|
||||
),
|
||||
default_selector: optionalNullableString(
|
||||
response.default_selector,
|
||||
"repository log response.default_selector",
|
||||
),
|
||||
limit: integer(response.limit, "repository log response.limit"),
|
||||
items: array(response.items, "repository log response.items").map((
|
||||
item,
|
||||
index,
|
||||
) => commitSummary(item, `repository log response.items[${index}]`)),
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"repository log response.diagnostics",
|
||||
).map(
|
||||
(item, index) =>
|
||||
diagnostic(item, `repository log response.diagnostics[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -3,25 +3,19 @@ import type {
|
||||
Method as PodProtocolMethod,
|
||||
Segment as PodProtocolSegment,
|
||||
} from "$lib/generated/protocol";
|
||||
import type {
|
||||
GitCommitSummary as SharedGitCommitSummary,
|
||||
GitRemoteSummary as SharedGitRemoteSummary,
|
||||
GitRepositorySummary as SharedGitRepositorySummary,
|
||||
RepositoryDetailResponse as SharedRepositoryDetailResponse,
|
||||
RepositoryListResponse as SharedRepositoryListResponse,
|
||||
RepositoryLogResponse as SharedRepositoryLogResponse,
|
||||
RepositorySummary as SharedRepositorySummary,
|
||||
WorkspaceResponse as SharedWorkspaceResponse,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
|
||||
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
|
||||
|
||||
export type ExtensionPoint = {
|
||||
status: string;
|
||||
note: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
record_authority: string;
|
||||
extension_points: {
|
||||
event_stream: ExtensionPoint;
|
||||
host_worker_bridge: ExtensionPoint;
|
||||
companion_console: ExtensionPoint;
|
||||
};
|
||||
};
|
||||
export type WorkspaceResponse = SharedWorkspaceResponse;
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
@@ -257,70 +251,13 @@ export type ListResponse<T> = {
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
source: {
|
||||
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
|
||||
uri: string;
|
||||
};
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at?: string | null;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git?: GitRepositorySummary | null;
|
||||
diagnostics?: Diagnostic[];
|
||||
};
|
||||
|
||||
export type GitRepositorySummary = {
|
||||
status: string;
|
||||
branch?: string | null;
|
||||
head?: string | null;
|
||||
dirty: boolean;
|
||||
remotes: GitRemoteSummary[];
|
||||
};
|
||||
|
||||
export type GitRemoteSummary = {
|
||||
name: string;
|
||||
fetch_url: string;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_date: string;
|
||||
parents: string[];
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type RepositoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: RepositorySummary[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RepositoryDetailResponse = {
|
||||
workspace_id: string;
|
||||
item: RepositorySummary;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
default_selector?: string | null;
|
||||
limit: number;
|
||||
items: GitCommitSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
export type RepositorySummary = SharedRepositorySummary;
|
||||
export type GitRepositorySummary = SharedGitRepositorySummary;
|
||||
export type GitRemoteSummary = SharedGitRemoteSummary;
|
||||
export type GitCommitSummary = SharedGitCommitSummary;
|
||||
export type RepositoryListResponse = SharedRepositoryListResponse;
|
||||
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
|
||||
export type RepositoryLogResponse = SharedRepositoryLogResponse;
|
||||
|
||||
export type MemoryDocumentResponse = {
|
||||
body_md: string;
|
||||
|
||||
@@ -132,9 +132,9 @@
|
||||
<code>{workspace.workspace_id}</code>
|
||||
{#if workspace.repositories[0]}
|
||||
<span class="workspace-repository-summary">
|
||||
{workspace.repositories[0].name}
|
||||
{workspace.repositories[0].display_name}
|
||||
<small>
|
||||
{workspace.repositories[0].default_ref ?? "repository default"} ·
|
||||
{workspace.repositories[0].default_selector ?? "repository default"} ·
|
||||
{workspace.repositories[0].kind}
|
||||
</small>
|
||||
</span>
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceResponse,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
WorkspaceResponse,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [workspace, repositories] = await Promise.all([
|
||||
loadJson<WorkspaceResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/workspace"),
|
||||
),
|
||||
loadJson<RepositoryListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/repositories"),
|
||||
),
|
||||
const [workspaceResult, repositoryResult] = await Promise.all([
|
||||
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
|
||||
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
|
||||
]);
|
||||
|
||||
if (!workspace.data) {
|
||||
let workspace = null;
|
||||
let workspaceError = workspaceResult.error;
|
||||
if (workspaceResult.data !== null) {
|
||||
try {
|
||||
workspace = parseWorkspaceResponse(workspaceResult.data);
|
||||
} catch (cause) {
|
||||
workspaceError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid workspace response";
|
||||
}
|
||||
}
|
||||
if (!workspace) {
|
||||
error(404, {
|
||||
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`,
|
||||
message: workspaceError ?? `Workspace ${workspaceId} is unavailable`,
|
||||
});
|
||||
}
|
||||
|
||||
let repositories = null;
|
||||
let repositoriesError = repositoryResult.error;
|
||||
if (repositoryResult.data !== null) {
|
||||
try {
|
||||
repositories = parseRepositoryListResponse(repositoryResult.data);
|
||||
} catch (cause) {
|
||||
repositoriesError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository list response";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: workspace.data,
|
||||
workspace,
|
||||
workspaceError: null,
|
||||
repositories: repositories.data,
|
||||
repositoriesError: repositories.error,
|
||||
repositories,
|
||||
repositoriesError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
RepositoryDetailResponse,
|
||||
RepositoryLogResponse,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
import {
|
||||
parseRepositoryDetailResponse,
|
||||
parseRepositoryLogResponse,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||
const workspaceId = params.workspaceId;
|
||||
const repositoryId = params.repositoryId;
|
||||
const [repository, log] = await Promise.all([
|
||||
loadJson<RepositoryDetailResponse>(
|
||||
const [repositoryResult, logResult] = await Promise.all([
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repositoryId)}`,
|
||||
),
|
||||
loadJson<RepositoryLogResponse>(
|
||||
),
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repositoryId)}/log`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
let repository = null;
|
||||
let repositoryError = repositoryResult.error;
|
||||
if (repositoryResult.data !== null) {
|
||||
try {
|
||||
repository = parseRepositoryDetailResponse(repositoryResult.data);
|
||||
} catch (cause) {
|
||||
repositoryError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository detail response";
|
||||
}
|
||||
}
|
||||
|
||||
let log = null;
|
||||
let logError = logResult.error;
|
||||
if (logResult.data !== null) {
|
||||
try {
|
||||
log = parseRepositoryLogResponse(logResult.data);
|
||||
} catch (cause) {
|
||||
logError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository log response";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryId,
|
||||
repository: repository.data,
|
||||
repositoryError: repository.error,
|
||||
repositoryLog: log.data,
|
||||
repositoryLogError: log.error,
|
||||
repository,
|
||||
repositoryError,
|
||||
repositoryLog: log,
|
||||
repositoryLogError: logError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,21 +55,30 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
|
||||
]));
|
||||
}
|
||||
if (url.includes("w-a")) {
|
||||
return Promise.resolve(Response.json([{
|
||||
return Promise.resolve(Response.json({
|
||||
workspace_id: "w-a",
|
||||
repository_id: "main",
|
||||
name: "Main",
|
||||
kind: "local_path",
|
||||
uri: "/srv/alpha",
|
||||
default_ref: "develop",
|
||||
}]));
|
||||
items: [{
|
||||
id: "main",
|
||||
display_name: "Main",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/srv/alpha" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:alpha",
|
||||
observed_status: "ready",
|
||||
default_selector: "develop",
|
||||
record_authority: "workspace-control-plane",
|
||||
}],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(new Response("unavailable", { status: 503 }));
|
||||
};
|
||||
|
||||
const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
|
||||
assertEquals(items.length, 2);
|
||||
assertEquals(items[0].repositories[0].repository_id, "main");
|
||||
assertEquals(items[0].repositories[0].id, "main");
|
||||
assertEquals(items[1].repositories, []);
|
||||
assertEquals(typeof items[1].repository_error, "string");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceResponse,
|
||||
} from "../src/lib/workspace/api/workspace-model.ts";
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes(expected)) return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("expected operation to throw");
|
||||
}
|
||||
|
||||
const repositoryList = {
|
||||
workspace_id: "w-a",
|
||||
items: [{
|
||||
id: "main",
|
||||
display_name: "Main",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/srv/alpha" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:alpha",
|
||||
observed_status: "ready",
|
||||
record_authority: "workspace-control-plane",
|
||||
}],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
Deno.test("generated repository wrapper validates current Backend JSON", () => {
|
||||
const parsed = parseRepositoryListResponse(repositoryList);
|
||||
if (parsed.items[0]?.id !== "main") {
|
||||
throw new Error("repository id was not preserved");
|
||||
}
|
||||
if (parsed.items[0]?.source.kind !== "local_path") {
|
||||
throw new Error("repository source kind was not preserved");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
|
||||
const stale = structuredClone(repositoryList) as Record<string, unknown>;
|
||||
const items = stale.items as Array<Record<string, unknown>>;
|
||||
items[0].repository_id = items[0].id;
|
||||
delete items[0].id;
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(stale),
|
||||
".repository_id is not part",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workspace response requires the permission projection", () => {
|
||||
const stale = {
|
||||
workspace_id: "w-a",
|
||||
display_name: "Alpha",
|
||||
record_authority: "workspace-control-plane",
|
||||
schema_version: 46,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "example.test",
|
||||
origin: "https://example.test",
|
||||
public_base_url: "https://example.test",
|
||||
cookie_name: "yoi_session",
|
||||
},
|
||||
},
|
||||
extension_points: {
|
||||
store: "sqlite",
|
||||
event_stream: { status: "available", note: "ready", diagnostics: [] },
|
||||
host_worker_bridge: {
|
||||
status: "available",
|
||||
note: "ready",
|
||||
diagnostics: [],
|
||||
},
|
||||
companion_console: {
|
||||
status: "available",
|
||||
note: "ready",
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
assertThrows(
|
||||
() => parseWorkspaceResponse(stale),
|
||||
"permissions must be an object",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user