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
4 changes: 4 additions & 0 deletions .changeset/fix-npm-publish-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Fix npm publish OIDC auth conflict and correct version to 3.0.0
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,10 @@ jobs:
git config user.name "scope3-wizard[bot]"
git config user.email "${{ vars.SCOPE3_WIZARD_APP_ID }}+scope3-wizard[bot]@users.noreply.github.com"

# setup-node used directly: internal action doesn't support registry-url
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'

- name: Install dependencies
run: npm ci
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @scope3/agentic-client

## 4.0.0
## 3.0.0

### Major Changes

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "scope3",
"version": "4.0.0",
"version": "3.0.0",
"description": "Scope3 SDK - REST and MCP client for the Agentic Platform",
"engines": {
"node": ">=18"
Expand Down
69 changes: 34 additions & 35 deletions src/__tests__/webhook-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ function makeRequest(options: {
});
}

/** Pick a random high port to avoid test collisions */
/** Use port 0 so the OS assigns a free ephemeral port */
function randomPort(): number {
return 10000 + Math.floor(Math.random() * 50000);
return 0;
}

describe('WebhookServer', () => {
Expand Down Expand Up @@ -184,17 +184,16 @@ describe('WebhookServer', () => {

describe('lifecycle', () => {
it('should start and begin listening', async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
const logSpy = jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`listening on port ${port}`));
logSpy.mockRestore();
});

it('should stop gracefully', async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
const logSpy = jest.spyOn(console, 'log').mockImplementation();
await server.start();
await server.stop();
Expand All @@ -208,11 +207,11 @@ describe('WebhookServer', () => {
});

it('should reject start when port is already in use', async () => {
port = randomPort();
const first = new WebhookServer({ port });
const first = new WebhookServer({ port: randomPort() });
await first.start();
const boundPort = parseInt(new URL(first.getUrl()).port);

const second = new WebhookServer({ port });
const second = new WebhookServer({ port: boundPort });
await expect(second.start()).rejects.toThrow();

await first.stop();
Expand All @@ -223,22 +222,22 @@ describe('WebhookServer', () => {

describe('GET /health', () => {
it('should return status ok', async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({ port, method: 'GET', path: '/health' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});

it('should respond to /health even when secret is configured', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'my-secret' });
server = new WebhookServer({ port: randomPort(), secret: 'my-secret' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

// /health is behind the auth middleware when secret is set,
// so it should require auth too (express middleware applies globally)
Expand All @@ -261,11 +260,11 @@ describe('WebhookServer', () => {

describe('event dispatch', () => {
beforeEach(async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);
});

it('should invoke the specific handler for a matching event type', async () => {
Expand Down Expand Up @@ -386,11 +385,11 @@ describe('WebhookServer', () => {

describe('invalid events', () => {
beforeEach(async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);
});

it('should return 400 when body is missing the type field', async () => {
Expand Down Expand Up @@ -451,11 +450,11 @@ describe('WebhookServer', () => {

describe('handler errors', () => {
beforeEach(async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);
});

it('should return 500 when a handler throws synchronously', async () => {
Expand Down Expand Up @@ -549,11 +548,11 @@ describe('WebhookServer', () => {

describe('authentication', () => {
it('should not require auth when no secret is configured', async () => {
port = randomPort();
server = new WebhookServer({ port });
server = new WebhookServer({ port: randomPort() });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand All @@ -565,10 +564,10 @@ describe('WebhookServer', () => {
});

it('should return 401 when secret is set and no Authorization header is sent', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'test-secret' });
server = new WebhookServer({ port: randomPort(), secret: 'test-secret' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand All @@ -581,10 +580,10 @@ describe('WebhookServer', () => {
});

it('should return 401 when the wrong token is provided', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'correct-secret' });
server = new WebhookServer({ port: randomPort(), secret: 'correct-secret' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand All @@ -597,10 +596,10 @@ describe('WebhookServer', () => {
});

it('should return 401 when Authorization header format is wrong', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'my-secret' });
server = new WebhookServer({ port: randomPort(), secret: 'my-secret' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand All @@ -613,10 +612,10 @@ describe('WebhookServer', () => {
});

it('should accept requests with the correct Bearer token', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'valid-token' });
server = new WebhookServer({ port: randomPort(), secret: 'valid-token' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const handler = jest.fn();
server.on('authed', handler);
Expand All @@ -634,10 +633,10 @@ describe('WebhookServer', () => {
});

it('should reject tokens of different length via timing-safe comparison', async () => {
port = randomPort();
server = new WebhookServer({ port, secret: 'short' });
server = new WebhookServer({ port: randomPort(), secret: 'short' });
jest.spyOn(console, 'log').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand All @@ -654,11 +653,11 @@ describe('WebhookServer', () => {

describe('custom webhook path', () => {
it('should accept events on custom path', async () => {
port = randomPort();
server = new WebhookServer({ port, path: '/custom/hooks' });
server = new WebhookServer({ port: randomPort(), path: '/custom/hooks' });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const handler = jest.fn();
server.on('test', handler);
Expand All @@ -675,11 +674,11 @@ describe('WebhookServer', () => {
});

it('should return 404 on default path when custom path is configured', async () => {
port = randomPort();
server = new WebhookServer({ port, path: '/custom/hooks' });
server = new WebhookServer({ port: randomPort(), path: '/custom/hooks' });
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
await server.start();
port = parseInt(new URL(server.getUrl()).port);

const res = await makeRequest({
port,
Expand Down
2 changes: 2 additions & 0 deletions src/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { timingSafeEqual } from 'crypto';
import express from 'express';
import type { Express, Request, Response, NextFunction } from 'express';
import { createServer, Server } from 'http';
import type { AddressInfo } from 'net';

export interface WebhookEvent<T = Record<string, unknown>> {
type: string;
Expand Down Expand Up @@ -124,6 +125,7 @@ export class WebhookServer {
this.server = createServer(this.app);
this.server.on('error', reject);
this.server.listen(this.config.port, () => {
this.config.port = (this.server!.address() as AddressInfo).port;
console.log(`Webhook server listening on port ${this.config.port}`);
console.log(`Webhook endpoint: http://localhost:${this.config.port}${this.config.path}`);
resolve();
Expand Down