Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
71 changes: 71 additions & 0 deletions mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,77 @@ export const handlers = [
});
}),

// ============================================================
// StackOne Actions RPC endpoint
// ============================================================
http.post('https://api.stackone.com/actions/rpc', async ({ request }) => {
const authHeader = request.headers.get('Authorization');

// Check for authentication
if (!authHeader || !authHeader.startsWith('Basic ')) {
return HttpResponse.json(
{ error: 'Unauthorized', message: 'Missing or invalid authorization header' },
{ status: 401 }
);
}

const body = (await request.json()) as {
action?: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
path?: Record<string, string>;
query?: Record<string, string>;
};

// Validate action is provided
if (!body.action) {
return HttpResponse.json(
{ error: 'Bad Request', message: 'Action is required' },
{ status: 400 }
);
}

// Return mock response based on action
if (body.action === 'hris_get_employee') {
return HttpResponse.json({
data: {
id: body.path?.id || 'test-id',
name: 'Test Employee',
...(body.body || {}),
},
});
}

if (body.action === 'hris_list_employees') {
return HttpResponse.json({
data: [
{ id: '1', name: 'Employee 1' },
{ id: '2', name: 'Employee 2' },
],
});
}

if (body.action === 'test_error_action') {
return HttpResponse.json(
{ error: 'Internal Server Error', message: 'Test error response' },
{ status: 500 }
);
}

// Default response for other actions
return HttpResponse.json({
data: {
action: body.action,
received: {
body: body.body,
headers: body.headers,
path: body.path,
query: body.query,
},
},
});
}),

// ============================================================
// StackOne Unified HRIS endpoints
// ============================================================
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
"dependencies": {
"@modelcontextprotocol/sdk": "catalog:prod",
"@orama/orama": "catalog:prod",
"@stackone/stackone-client-ts": "catalog:prod",
"json-schema": "catalog:prod"
Comment on lines 33 to 36
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The rpc-client.ts file imports and uses Zod for runtime validation, but zod is listed in devDependencies instead of dependencies. This will cause runtime errors when the package is installed in production environments.

Move zod from devDependencies to dependencies to ensure it's available at runtime.

Copilot uses AI. Check for mistakes.
},
"devDependencies": {
Expand Down
13 changes: 0 additions & 13 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,12 @@ catalogs:
prod:
'@modelcontextprotocol/sdk': ^1.19.1
'@orama/orama': ^3.1.11
'@stackone/stackone-client-ts': 4.32.2
json-schema: ^0.4.0
Comment on lines 32 to 35
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

Zod is currently listed in the dev catalog (line 28) but it's used at runtime in src/rpc-client.ts. It should be moved to the prod catalog to ensure it's available in production environments.

Add zod to the prod catalog:

prod:
  '@modelcontextprotocol/sdk': ^1.19.1
  '@orama/orama': ^3.1.11
  json-schema: ^0.4.0
  zod: ^3.23.8

And remove it from the dev catalog, or keep it in both if it's also used in dev dependencies.

Copilot uses AI. Check for mistakes.

enablePrePostScripts: true

minimumReleaseAge: 1440

minimumReleaseAgeExclude:
- '@stackone/stackone-client-ts'

onlyBuiltDependencies:
- '@biomejs/biome'
- esbuild
Expand Down
File renamed without changes.
104 changes: 104 additions & 0 deletions src/rpc-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { RpcClient } from './rpc-client';
import { StackOneAPIError } from './utils/errors';

test('should successfully execute an RPC action', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'hris_get_employee',
body: { fields: 'name,email' },
path: { id: 'emp-123' },
});

// Response matches server's ActionsRpcResponseApiModel structure
expect(response).toHaveProperty('data');
expect(response.data).toMatchObject({
id: 'emp-123',
name: 'Test Employee',
});
});

test('should send correct payload structure', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'custom_action',
body: { key: 'value' },
headers: { 'x-custom': 'header' },
path: { id: '123' },
query: { filter: 'active' },
});

// Response matches server's ActionsRpcResponseApiModel structure
expect(response.data).toMatchObject({
action: 'custom_action',
received: {
body: { key: 'value' },
headers: { 'x-custom': 'header' },
path: { id: '123' },
query: { filter: 'active' },
},
});
});

test('should handle list actions with array data', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'hris_list_employees',
});

// Response data can be an array (matches RpcActionResponseData union type)
expect(Array.isArray(response.data)).toBe(true);
expect(response.data).toMatchObject([
{ id: expect.any(String), name: expect.any(String) },
{ id: expect.any(String), name: expect.any(String) },
]);
});

test('should throw StackOneAPIError on server error', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

await expect(
client.actions.rpcAction({
action: 'test_error_action',
})
).rejects.toThrow(StackOneAPIError);
});

test('should include request body in error for debugging', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

await expect(
client.actions.rpcAction({
action: 'test_error_action',
body: { debug: 'data' },
})
).rejects.toMatchObject({
statusCode: 500,
requestBody: { action: 'test_error_action' },
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 9, 2025

Choose a reason for hiding this comment

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

P2: The test assertion doesn't verify that the body field is included in requestBody. Based on the test name "should include request body in error for debugging" and the request including body: { debug: 'data' }, the assertion should verify this data is captured.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/rpc-client.spec.ts, line 86:

<comment>The test assertion doesn&#39;t verify that the `body` field is included in `requestBody`. Based on the test name &quot;should include request body in error for debugging&quot; and the request including `body: { debug: &#39;data&#39; }`, the assertion should verify this data is captured.</comment>

<file context>
@@ -0,0 +1,100 @@
+    })
+  ).rejects.toMatchObject({
+    statusCode: 500,
+    requestBody: { action: &#39;test_error_action&#39; },
+  });
+});
</file context>
Fix with Cubic

Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The requestBody assertion is incomplete. The test expects { action: 'test_error_action' } but the actual request body should also include the body field with { debug: 'data' }. This assertion won't properly validate the error debugging information.

Consider updating the assertion to:

requestBody: { 
  action: 'test_error_action',
  body: { debug: 'data' }
}
Suggested change
requestBody: { action: 'test_error_action' },
requestBody: { action: 'test_error_action', body: { debug: 'data' } },

Copilot uses AI. Check for mistakes.
});
});

test('should work with only action parameter', async () => {
const client = new RpcClient({
security: { username: 'test-api-key' },
});

const response = await client.actions.rpcAction({
action: 'simple_action',
});

// Response has data field (server returns { data: { action, received } })
expect(response).toHaveProperty('data');
});
149 changes: 149 additions & 0 deletions src/rpc-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { z } from 'zod';
import { StackOneAPIError } from './utils/errors';

Comment on lines +2 to +3
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The import JsonDict from './types' is unused in this file. Consider removing it to keep the imports clean.

Suggested change
import { StackOneAPIError } from './utils/errors';

Copilot uses AI. Check for mistakes.
/**
* Zod schema for RPC action request validation
* @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
*/
const rpcActionRequestSchema = z.object({
action: z.string(),
body: z.record(z.unknown()).optional(),
headers: z.record(z.unknown()).optional(),
path: z.record(z.unknown()).optional(),
query: z.record(z.unknown()).optional(),
});

/**
* RPC action request payload
*/
export type RpcActionRequest = z.infer<typeof rpcActionRequestSchema>;

/**
* Zod schema for RPC action response data
*/
const rpcActionResponseDataSchema = z.union([
z.record(z.unknown()),
z.array(z.record(z.unknown())),
z.null(),
]);

/**
* Zod schema for RPC action response validation
*
* The server returns a flexible JSON structure. Known fields:
* - `data`: The main response data (object, array, or null)
* - `next`: Pagination cursor for fetching next page
*
* Additional fields from the connector response are passed through.
* @see unified-cloud-api/src/unified-api-v2/unifiedAPIv2.service.ts processActionCall
*/
const rpcActionResponseSchema = z
.object({
next: z.string().nullish(),
data: rpcActionResponseDataSchema.optional(),
})
.passthrough();

/**
* RPC action response data type - can be object, array of objects, or null
*/
export type RpcActionResponseData = z.infer<typeof rpcActionResponseDataSchema>;

/**
* RPC action response from the StackOne API
* Contains known fields (data, next) plus any additional fields from the connector
*/
export type RpcActionResponse = z.infer<typeof rpcActionResponseSchema>;

/**
* Zod schema for RPC client configuration validation
*/
const rpcClientConfigSchema = z.object({
serverURL: z.string().optional(),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 9, 2025

Choose a reason for hiding this comment

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

P1: Rule violated: Flag Security Vulnerabilities

The serverURL config parameter accepts any string without validating HTTPS. Since authentication credentials (Basic auth header with username/password) are sent to this URL, a misconfigured HTTP URL would transmit credentials in plaintext. Add HTTPS validation to the schema to prevent credential exposure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/rpc-client.ts, line 37:

<comment>The `serverURL` config parameter accepts any string without validating HTTPS. Since authentication credentials (Basic auth header with username/password) are sent to this URL, a misconfigured HTTP URL would transmit credentials in plaintext. Add HTTPS validation to the schema to prevent credential exposure.</comment>

<file context>
@@ -0,0 +1,128 @@
+ * Zod schema for RPC client configuration validation
+ */
+const rpcClientConfigSchema = z.object({
+  serverURL: z.string().optional(),
+  security: z.object({
+    username: z.string(),
</file context>
Fix with Cubic

security: z.object({
username: z.string(),
password: z.string().optional(),
}),
});

/**
* Configuration for the RPC client
*/
export type RpcClientConfig = z.infer<typeof rpcClientConfigSchema>;

/**
* Custom RPC client for StackOne API.
* Replaces the @stackone/stackone-client-ts dependency.
*
* @see https://docs.stackone.com/platform/api-reference/actions/list-all-actions-metadata
* @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
*/
export class RpcClient {
private readonly baseUrl: string;
private readonly authHeader: string;

constructor(config: RpcClientConfig) {
const validatedConfig = rpcClientConfigSchema.parse(config);
this.baseUrl = validatedConfig.serverURL || 'https://api.stackone.com';
const username = validatedConfig.security.username;
const password = validatedConfig.security.password || '';
this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}

/**
* Actions namespace containing RPC methods
*/
readonly actions = {
/**
* Execute an RPC action
* @param request The RPC action request
* @returns The RPC action response matching server's ActionsRpcResponseApiModel
*/
rpcAction: async (request: RpcActionRequest): Promise<RpcActionResponse> => {
const validatedRequest = rpcActionRequestSchema.parse(request);
const url = `${this.baseUrl}/actions/rpc`;

const requestBody = {
action: validatedRequest.action,
body: validatedRequest.body,
headers: validatedRequest.headers,
path: validatedRequest.path,
query: validatedRequest.query,
} as const satisfies RpcActionRequest;

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
'User-Agent': 'stackone-ai-node',
},
body: JSON.stringify(requestBody),
});

const responseBody: unknown = await response.json();

if (!response.ok) {
throw new StackOneAPIError(
`RPC action failed for ${url}`,
response.status,
responseBody,
requestBody
);
}

const validation = rpcActionResponseSchema.safeParse(responseBody);

if (!validation.success) {
throw new StackOneAPIError(
`Invalid RPC action response for ${url}`,
response.status,
responseBody,
requestBody
);
}
Comment on lines +138 to +144
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

The error handling for response validation uses response.status which would be 200 (OK) when validation fails. This is misleading since the issue is with the response format, not an HTTP error. Consider using a different status code (e.g., 500) or creating a separate error type for validation failures.

Additionally, response.json() on line 99 can throw if the response body is not valid JSON, but this is not caught. Consider wrapping it in a try-catch to handle malformed responses gracefully.

Copilot uses AI. Check for mistakes.

return validation.data;
},
};
}
Loading
Loading