fix: validate workspace repository browser payloads

This commit is contained in:
2026-09-01 01:49:38 +09:00
parent d748274905
commit 6b20ceac46
9 changed files with 992 additions and 181 deletions
@@ -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 = { import {
workspace_id: string; parseRepositoryListResponse,
owner_account_id: string | null; parseWorkspaceCatalogResponse,
display_name: string; parseWorkspaceCreateResponse,
state: string; type RepositorySummary,
created_at: string; type WorkspaceCreateResponse,
updated_at: string; type WorkspaceSummary,
}; } from "$lib/workspace/api/workspace-model";
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;
};
export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
repositories: WorkspaceRepositoryRecord[]; repositories: RepositorySummary[];
repository_error?: string; repository_error?: string;
}; };
export type CreateWorkspaceResponse = WorkspaceCreateResponse;
export type CreateWorkspaceRequest = { export type CreateWorkspaceRequest = {
operation_key: string; 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 { export class WorkspaceCatalogError extends Error {
constructor( constructor(
public readonly status: number | null, public readonly status: number | null,
@@ -70,20 +39,21 @@ type Fetch = typeof globalThis.fetch;
export async function listWorkspaces( export async function listWorkspaces(
fetcher: Fetch, fetcher: Fetch,
): Promise<WorkspaceCatalogRecord[]> { ): Promise<WorkspaceCatalogRecord[]> {
return await fetchJson<WorkspaceCatalogRecord[]>( return parseWorkspaceCatalogResponse(
fetcher, await fetchJson(fetcher, "/api/workspaces?limit=200"),
"/api/workspaces?limit=200",
); );
} }
export async function listWorkspaceRepositories( export async function listWorkspaceRepositories(
fetcher: Fetch, fetcher: Fetch,
workspaceId: string, workspaceId: string,
): Promise<WorkspaceRepositoryRecord[]> { ): Promise<RepositorySummary[]> {
return await fetchJson<WorkspaceRepositoryRecord[]>( return parseRepositoryListResponse(
await fetchJson(
fetcher, fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`, `/api/w/${encodeURIComponent(workspaceId)}/repositories`,
); ),
).items;
} }
export async function loadWorkspaceCatalog( export async function loadWorkspaceCatalog(
@@ -115,11 +85,13 @@ export async function createWorkspace(
fetcher: Fetch, fetcher: Fetch,
request: CreateWorkspaceRequest, request: CreateWorkspaceRequest,
): Promise<CreateWorkspaceResponse> { ): Promise<CreateWorkspaceResponse> {
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", { return parseWorkspaceCreateResponse(
await fetchJson(fetcher, "/api/workspaces", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(request), body: JSON.stringify(request),
}); }),
);
} }
export function creationErrorMessage(error: unknown): string { export function creationErrorMessage(error: unknown): string {
@@ -150,11 +122,11 @@ export function createOperationKey(): string {
}`; }`;
} }
async function fetchJson<T>( async function fetchJson(
fetcher: Fetch, fetcher: Fetch,
input: string, input: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<unknown> {
let response: Response; let response: Response;
try { try {
response = await fetcher(input, init); response = await fetcher(input, init);
@@ -172,7 +144,7 @@ async function fetchJson<T>(
} }
throw new WorkspaceCatalogError(response.status, detail); throw new WorkspaceCatalogError(response.status, detail);
} }
return await response.json() as T; return await response.json() as unknown;
} }
function errorMessage(error: unknown): string { 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, Method as PodProtocolMethod,
Segment as PodProtocolSegment, Segment as PodProtocolSegment,
} from "$lib/generated/protocol"; } 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 { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
export type WorkspaceResponse = SharedWorkspaceResponse;
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 Diagnostic = { export type Diagnostic = {
code: string; code: string;
@@ -257,70 +251,13 @@ export type ListResponse<T> = {
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };
export type RepositorySummary = { export type RepositorySummary = SharedRepositorySummary;
id: string; export type GitRepositorySummary = SharedGitRepositorySummary;
display_name: string; export type GitRemoteSummary = SharedGitRemoteSummary;
kind: string; export type GitCommitSummary = SharedGitCommitSummary;
provider: string; export type RepositoryListResponse = SharedRepositoryListResponse;
source: { export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid"; export type RepositoryLogResponse = SharedRepositoryLogResponse;
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 MemoryDocumentResponse = { export type MemoryDocumentResponse = {
body_md: string; body_md: string;
+2 -2
View File
@@ -132,9 +132,9 @@
<code>{workspace.workspace_id}</code> <code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]} {#if workspace.repositories[0]}
<span class="workspace-repository-summary"> <span class="workspace-repository-summary">
{workspace.repositories[0].name} {workspace.repositories[0].display_name}
<small> <small>
{workspace.repositories[0].default_ref ?? "repository default"} · {workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind} {workspace.repositories[0].kind}
</small> </small>
</span> </span>
@@ -1,34 +1,51 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
export const load: LayoutLoad = async ({ fetch, params }) => { export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const [workspace, repositories] = await Promise.all([ const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<WorkspaceResponse>( loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
fetch, loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
workspaceApiPath(workspaceId, "/workspace"),
),
loadJson<RepositoryListResponse>(
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, { 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 { return {
workspace: workspace.data, workspace,
workspaceError: null, workspaceError: null,
repositories: repositories.data, repositories,
repositoriesError: repositories.error, repositoriesError,
}; };
}; };
@@ -1,29 +1,59 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { import {
RepositoryDetailResponse, parseRepositoryDetailResponse,
RepositoryLogResponse, parseRepositoryLogResponse,
} from "$lib/workspace/sidebar/types"; } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const workspaceId = params.workspaceId;
const repositoryId = params.repositoryId; const repositoryId = params.repositoryId;
const [repository, log] = await Promise.all([ const [repositoryResult, logResult] = await Promise.all([
loadJson<RepositoryDetailResponse>( loadJson<unknown>(
fetch, fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`), workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryId)}`,
), ),
loadJson<RepositoryLogResponse>( ),
loadJson<unknown>(
fetch, 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 { return {
repositoryId, repositoryId,
repository: repository.data, repository,
repositoryError: repository.error, repositoryError,
repositoryLog: log.data, repositoryLog: log,
repositoryLogError: log.error, repositoryLogError: logError,
}; };
}; };
+17 -8
View File
@@ -55,21 +55,30 @@ Deno.test("workspace catalog enriches each visible workspace without dropping si
])); ]));
} }
if (url.includes("w-a")) { if (url.includes("w-a")) {
return Promise.resolve(Response.json([{ return Promise.resolve(Response.json({
workspace_id: "w-a", workspace_id: "w-a",
repository_id: "main", items: [{
name: "Main", id: "main",
kind: "local_path", display_name: "Main",
uri: "/srv/alpha", kind: "git",
default_ref: "develop", 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 })); return Promise.resolve(new Response("unavailable", { status: 503 }));
}; };
const items = await loadWorkspaceCatalog(fetcher as typeof fetch); const items = await loadWorkspaceCatalog(fetcher as typeof fetch);
assertEquals(items.length, 2); assertEquals(items.length, 2);
assertEquals(items[0].repositories[0].repository_id, "main"); assertEquals(items[0].repositories[0].id, "main");
assertEquals(items[1].repositories, []); assertEquals(items[1].repositories, []);
assertEquals(typeof items[1].repository_error, "string"); 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",
);
});