Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@paws/domain-policy": "workspace:*",
"@paws/domain-session": "workspace:*",
"@paws/domain-snapshot": "workspace:*",
"@paws/domain-workspace": "workspace:*",
"@paws/integrations": "workspace:*",
"@paws/logger": "workspace:*",
"@paws/provider-aws-ec2": "workspace:*",
Expand Down
79 changes: 79 additions & 0 deletions apps/control-plane/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import { createLogger } from '@paws/logger';
import { tracingMiddleware } from '@paws/telemetry';
import {
createWorkspaceStore,
CreateWorkspaceRequestSchema,
type Workspace,
} from '@paws/domain-workspace';
import type { Hono } from 'hono';
import {
verifyWebhookSignature,
Expand Down Expand Up @@ -188,6 +193,7 @@
deps.sessionStore ?? (deps.db ? createSqliteSessionStore(deps.db) : createSessionStore());
const daemonStore =
deps.daemonStore ?? (deps.db ? createSqliteDaemonStore(deps.db) : createDaemonStore());
const workspaceStore = createWorkspaceStore();
const governance = deps.governance ?? createGovernanceChecker();
const sessionEvents = createSessionEvents();

Expand Down Expand Up @@ -960,6 +966,79 @@
return c.json({ role, status: 'stopped' as const }, 200);
});

// --- Workspaces ---

Comment on lines +969 to +970
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing auth middleware for workspace routes.

The workspace CRUD endpoints lack auth middleware registration. Other /v1/* domain routes (e.g., daemons, sessions, snapshots) have explicit auth middleware applied in lines 551-573. Without this, workspace routes are unprotected.

🔒 Proposed fix to add auth middleware for workspace routes

Add these lines in the auth middleware registration section (around line 573):

 app.use('/v1/mcp/*', authMiddleware(authConfig));
 app.use('/v1/mcp', authMiddleware(authConfig));
 app.use('/v1/settings', authMiddleware(authConfig));
 app.use('/v1/settings/*', authMiddleware(authConfig));
+app.use('/v1/workspaces', authMiddleware(authConfig));
+app.use('/v1/workspaces/*', authMiddleware(authConfig));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/control-plane/src/app.ts` around lines 972 - 973, The workspace CRUD
endpoints under /v1/workspaces are missing the auth middleware registration;
update the auth middleware registration block (the same place where daemons,
sessions, and snapshots are protected around the existing middleware setup) to
attach the same authentication middleware (e.g., ensureAuthenticated /
authMiddleware used for daemons/sessions/snapshots) to the workspace routes (the
router or handler that registers the workspace CRUD endpoints, such as the
workspacesRoutes or router handling /v1/workspaces) so all
create/read/update/delete workspace endpoints are protected.

app.post('/v1/workspaces', async (c) => {
const raw = await c.req.json();
const parsed = CreateWorkspaceRequestSchema.safeParse(raw);
if (!parsed.success) {
return c.json({ error: { code: 'VALIDATION_ERROR', message: parsed.error.message } }, 400);
}
const body = parsed.data;
if (workspaceStore.getByName(body.name)) {
return c.json(
{
error: {
code: 'WORKSPACE_ALREADY_EXISTS',
message: `Workspace '${body.name}' already exists`,
},
},
409,
);
}
const workspace = workspaceStore.create({
id: randomUUID(),
name: body.name,
description: body.description ?? '',
type: body.type,
repos: body.repos,
settings: body.settings ?? {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return c.json(workspace, 201);
});

app.get('/v1/workspaces', (c) => {
return c.json({ workspaces: workspaceStore.list() }, 200);
});

app.get('/v1/workspaces/:id', (c) => {
const workspace = workspaceStore.get(c.req.param('id'));
if (!workspace) {
return c.json(
{ error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } },
404,
);
}
return c.json(workspace, 200);
});

app.put('/v1/workspaces/:id', async (c) => {
const id = c.req.param('id');
if (!workspaceStore.get(id)) {
return c.json(
{ error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } },
404,
);
}
const body = await c.req.json();
const updated = workspaceStore.update(id, body as Partial<Workspace>);
return c.json(updated, 200);
});

app.delete('/v1/workspaces/:id', (c) => {
const id = c.req.param('id');
if (!workspaceStore.get(id)) {
return c.json(
{ error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } },
404,
);
}
workspaceStore.delete(id);
return c.json({ id, deleted: true }, 200);
});

// --- Templates ---

const templateStore = createTemplateStore();
Expand Down Expand Up @@ -1357,7 +1436,7 @@

// Build env vars — common + event-type-specific
const envVars: Record<string, string> = {
...((fullDaemon ?? daemon).workload?.env ?? {}),

Check warning on line 1439 in apps/control-plane/src/app.ts

View workflow job for this annotation

GitHub Actions / check

eslint-plugin-unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
TRIGGER_PAYLOAD: JSON.stringify(event),
TRIGGER_TYPE: event.type,
GITHUB_REPO: event.repo,
Expand Down
1 change: 1 addition & 0 deletions apps/control-plane/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
{ "path": "../../packages/domains/policy" },
{ "path": "../../packages/domains/session" },
{ "path": "../../packages/domains/snapshot" },
{ "path": "../../packages/domains/workspace" },
{ "path": "../../packages/integrations" },
{ "path": "../../packages/logger" },
{ "path": "../../packages/providers" },
Expand Down
85 changes: 85 additions & 0 deletions apps/dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,91 @@ export async function getSystemInfo(): Promise<SystemInfo> {
return res.json();
}

// --- Workspaces ---

export interface WorkspaceRepo {
name: string;
role?: 'primary' | 'reference';
branch?: string;
}

export interface Workspace {
id: string;
name: string;
description?: string;
type: 'monorepo' | 'multi-repo';
repos: WorkspaceRepo[];
rootDir?: string;
settings?: {
language?: string;
packageManager?: string;
testCommand?: string;
buildCommand?: string;
};
daemonCount?: number;
createdAt: string;
updatedAt?: string;
}

export async function listWorkspaces(): Promise<{ workspaces: Workspace[] }> {
const res = await fetch('/v1/workspaces', { headers: apiKeyHeaders() });
if (!res.ok) throw new Error(`Failed to fetch workspaces: ${res.status}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace generic Error throws with typed workspace error codes.

Line 580, Line 588, Line 602-605, Line 621-624, and Line 634 throw generic Error, which removes machine-readable error semantics for callers.

Proposed fix
+type WorkspaceErrorCode =
+  | 'WORKSPACE_LIST_FAILED'
+  | 'WORKSPACE_GET_FAILED'
+  | 'WORKSPACE_CREATE_FAILED'
+  | 'WORKSPACE_UPDATE_FAILED'
+  | 'WORKSPACE_DELETE_FAILED';
+
+type WorkspaceApiError = Error & {
+  code: WorkspaceErrorCode;
+  status: number;
+};
+
+function createWorkspaceApiError(
+  code: WorkspaceErrorCode,
+  status: number,
+  message: string,
+): WorkspaceApiError {
+  return Object.assign(new Error(message), { code, status });
+}
...
-  if (!res.ok) throw new Error(`Failed to fetch workspaces: ${res.status}`);
+  if (!res.ok) {
+    throw createWorkspaceApiError('WORKSPACE_LIST_FAILED', res.status, `Failed to fetch workspaces: ${res.status}`);
+  }

As per coding guidelines, "Use typed errors with error codes (e.g., DaemonsError), not generic Error."

Also applies to: 588-588, 602-605, 621-624, 634-634

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/api/client.ts` at line 580, Replace the generic throws
(e.g., "throw new Error(`Failed to fetch workspaces: ${res.status}`)" and the
other throw sites at the indicated locations) with a typed workspace error that
carries an error code so callers can handle them programmatically; instantiate
and throw the project-specific error class (e.g., DaemonsError or
WorkspaceError) with a distinct code like WORKSPACE_FETCH_FAILED or
WORKSPACE_NOT_FOUND and include the original message/status as context. Locate
the throw sites in apps/dashboard/src/api/client.ts that use the local res
variable and the fetch/parse branches and replace each "new Error(...)" with
"new DaemonsError('WORKSPACE_<SPECIFIC_CODE>', `<descriptive message including
${res.status} or details>`)" (or the established WorkspaceError class used
across the codebase), preserving original message text as the error detail.
Ensure import of the error class is added if missing and use unique error codes
per failure case (e.g., WORKSPACE_FETCH_FAILED, WORKSPACE_PARSE_FAILED,
WORKSPACE_DELETE_FAILED) to match coding guidelines.

return res.json();
}
Comment on lines +578 to +582
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the file
find . -type f -name "client.ts" | grep -E "apps/dashboard.*api"

Repository: arek-e/paws

Length of output: 89


🏁 Script executed:

# Get file size to understand how much to read
wc -l apps/dashboard/src/api/client.ts

Repository: arek-e/paws

Length of output: 91


🏁 Script executed:

# Read the relevant section of the file (around lines 578-627)
sed -n '570,635p' apps/dashboard/src/api/client.ts

Repository: arek-e/paws

Length of output: 2076


🏁 Script executed:

# Check if Zod is already imported in the file
grep -n "zod\|Zod" apps/dashboard/src/api/client.ts | head -20

Repository: arek-e/paws

Length of output: 37


🏁 Script executed:

# Check for the Workspace type definition
grep -n "type Workspace\|interface Workspace" apps/dashboard/src/api/client.ts

Repository: arek-e/paws

Length of output: 124


🏁 Script executed:

# Get the full Workspace and WorkspaceRepo type definitions
sed -n '554,575p' apps/dashboard/src/api/client.ts

Repository: arek-e/paws

Length of output: 515


🏁 Script executed:

# Check if Zod is used elsewhere in the dashboard project
find apps/dashboard -type f \( -name "*.ts" -o -name "*.tsx" \) -exec grep -l "from 'zod'\|from \"zod\"" {} \;

Repository: arek-e/paws

Length of output: 37


🏁 Script executed:

# Look at the imports at the top of the client.ts file to understand dependencies
head -30 apps/dashboard/src/api/client.ts

Repository: arek-e/paws

Length of output: 950


Add Zod validation for workspace API request/response payloads.

The listWorkspaces(), getWorkspace(), createWorkspace(), and updateWorkspace() functions return unvalidated JSON responses, and createWorkspace() and updateWorkspace() submit request bodies without validation. This creates runtime brittleness if the API schema drifts from the TypeScript interface definitions.

Proposed fix
+import { z } from 'zod';
+
+const WorkspaceRepoSchema = z.object({
+  name: z.string(),
+  role: z.enum(['primary', 'reference']).optional(),
+  branch: z.string().optional(),
+});
+
+const WorkspaceSchema = z.object({
+  id: z.string(),
+  name: z.string(),
+  description: z.string().optional(),
+  type: z.enum(['monorepo', 'multi-repo']),
+  repos: z.array(WorkspaceRepoSchema),
+  rootDir: z.string().optional(),
+  settings: z
+    .object({
+      language: z.string().optional(),
+      packageManager: z.string().optional(),
+      testCommand: z.string().optional(),
+      buildCommand: z.string().optional(),
+    })
+    .optional(),
+  daemonCount: z.number().optional(),
+  createdAt: z.string(),
+  updatedAt: z.string().optional(),
+});
+
+const WorkspaceListSchema = z.object({
+  workspaces: z.array(WorkspaceSchema),
+});
+
 export async function listWorkspaces(): Promise<{ workspaces: Workspace[] }> {
   const res = await fetch('/v1/workspaces', { headers: apiKeyHeaders() });
   if (!res.ok) throw new Error(`Failed to fetch workspaces: ${res.status}`);
-  return res.json();
+  return WorkspaceListSchema.parse(await res.json());
 }

Per coding guidelines: "Use Zod for all external data validation: API requests, config files, and environment variables."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/api/client.ts` around lines 578 - 582, The API functions
listWorkspaces, getWorkspace, createWorkspace, and updateWorkspace currently
send/receive raw JSON, so add Zod schemas for the Workspace shape and for
list/individual responses and use them to validate: parse and validate response
bodies with schema.parse(resJson) and throw a descriptive error on parse
failures; for createWorkspace and updateWorkspace validate the request payload
with the corresponding Zod input schema before calling fetch and send the
validated object as the body; derive TypeScript types via z.infer<> and replace
direct returns with the parsed/typed results to ensure runtime safety and clear
error messages (reference the functions listWorkspaces, getWorkspace,
createWorkspace, updateWorkspace and the central Workspace schema you add).


export async function getWorkspace(id: string): Promise<Workspace> {
const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, {
headers: apiKeyHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch workspace: ${res.status}`);
return res.json();
}

export async function createWorkspace(
data: Omit<Workspace, 'id' | 'createdAt' | 'updatedAt' | 'daemonCount'>,
): Promise<Workspace> {
const res = await fetch('/v1/workspaces', {
method: 'POST',
headers: apiKeyHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
(body as { error?: { message?: string } }).error?.message ??
`Failed to create workspace: ${res.status}`,
);
}
return res.json();
}

export async function updateWorkspace(
id: string,
data: Partial<Omit<Workspace, 'id' | 'createdAt' | 'updatedAt' | 'daemonCount'>>,
): Promise<Workspace> {
const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: apiKeyHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
(body as { error?: { message?: string } }).error?.message ??
`Failed to update workspace: ${res.status}`,
);
}
return res.json();
}

export async function deleteWorkspace(id: string): Promise<void> {
const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: apiKeyHeaders(),
});
if (!res.ok) throw new Error(`Failed to delete workspace: ${res.status}`);
}

// --- Cloud Connections (AWS integration) ---

export interface CloudConnection {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PAGES = [
{ name: 'Fleet', path: '/fleet', group: 'Infrastructure' },
{ name: 'Servers', path: '/servers', group: 'Infrastructure' },
{ name: 'Snapshots', path: '/snapshots', group: 'Infrastructure' },
{ name: 'Workspaces', path: '/workspaces', group: 'Agents' },
{ name: 'Daemons', path: '/daemons', group: 'Agents' },
{ name: 'Templates', path: '/templates', group: 'Agents' },
{ name: 'Sessions', path: '/sessions', group: 'Agents' },
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function SidebarNav({
<SidebarLink to="/snapshots" label="Snapshots" onClick={onNavigate} collapsed={collapsed} />

<SectionLabel collapsed={collapsed}>Agents</SectionLabel>
<SidebarLink to="/workspaces" label="Workspaces" onClick={onNavigate} collapsed={collapsed} />
<SidebarLink to="/daemons" label="Daemons" onClick={onNavigate} collapsed={collapsed} />
<SidebarLink to="/templates" label="Templates" onClick={onNavigate} collapsed={collapsed} />
<SidebarLink to="/sessions" label="Sessions" onClick={onNavigate} collapsed={collapsed} />
Expand Down
Loading
Loading