feat(bridge): M2 Bridge MVP with HTTP endpoints
This commit is contained in:
parent
0042fb3df0
commit
dbf290e49a
5 changed files with 2422 additions and 39 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@kanban/bridge",
|
"name": "@kanban/bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -19,15 +19,22 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kanban/contracts": "workspace:*",
|
"@kanban/contracts": "workspace:*",
|
||||||
|
"express": "^4.21.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"tsx": "^4.16.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"tsx": "^4.16.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
281
packages/bridge/src/__tests__/routes.test.ts
Normal file
281
packages/bridge/src/__tests__/routes.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// @kanban/bridge — Route integration tests (vitest)
|
||||||
|
// Tests the Express app with mock Hermes CLI responses
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp, type BridgeConfig, VERSION } from '../index.js';
|
||||||
|
import type { Board, TaskSummary, TaskDetail } from '@kanban/contracts';
|
||||||
|
|
||||||
|
// ─── Mock the HermesKanbanClient ─────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('../hermes-client.js', () => {
|
||||||
|
const mockBoards: Board[] = [
|
||||||
|
{ slug: 'default', name: 'Default Board', current: true },
|
||||||
|
{ slug: 'backend', name: 'Backend Board' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTasks: TaskSummary[] = [
|
||||||
|
{
|
||||||
|
id: 'T-001',
|
||||||
|
title: 'Implement auth',
|
||||||
|
status: 'todo',
|
||||||
|
assignee: null,
|
||||||
|
priority: 1,
|
||||||
|
createdAt: '2026-07-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'T-002',
|
||||||
|
title: 'Write tests',
|
||||||
|
status: 'running',
|
||||||
|
assignee: 'hermes',
|
||||||
|
priority: 2,
|
||||||
|
createdAt: '2026-07-10T01:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTaskDetail: TaskDetail = {
|
||||||
|
id: 'T-001',
|
||||||
|
title: 'Implement auth',
|
||||||
|
status: 'todo',
|
||||||
|
body: 'Implement Telegram initData validation',
|
||||||
|
assignee: null,
|
||||||
|
priority: 1,
|
||||||
|
createdAt: '2026-07-10T00:00:00Z',
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: 'C-001',
|
||||||
|
author: 'hermes',
|
||||||
|
body: 'Starting implementation',
|
||||||
|
createdAt: '2026-07-10T00:30:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runs: [],
|
||||||
|
events: [],
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedTask: TaskDetail = {
|
||||||
|
...mockTaskDetail,
|
||||||
|
id: 'T-003',
|
||||||
|
title: 'New task from API',
|
||||||
|
status: 'triage',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
HermesKanbanClient: vi.fn().mockImplementation(() => ({
|
||||||
|
listBoards: vi.fn().mockResolvedValue(mockBoards),
|
||||||
|
listTasks: vi.fn().mockResolvedValue(mockTasks),
|
||||||
|
getTask: vi.fn().mockImplementation((_board: string, taskId: string) => {
|
||||||
|
if (taskId === 'T-001') return Promise.resolve(mockTaskDetail);
|
||||||
|
return Promise.reject(new Error(`No such task: ${taskId}`));
|
||||||
|
}),
|
||||||
|
createTask: vi.fn().mockResolvedValue(mockCreatedTask),
|
||||||
|
addComment: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test config ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const devConfig: BridgeConfig = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0, // random port for testing
|
||||||
|
bridgeToken: 'test-token',
|
||||||
|
hermesHome: '/tmp/hermes-test',
|
||||||
|
hermesBin: 'echo', // won't actually be called due to mock
|
||||||
|
commandTimeoutSeconds: 5,
|
||||||
|
allowedBoards: ['default', 'backend'],
|
||||||
|
logTailMaxLines: 100,
|
||||||
|
authMode: 'dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenConfig: BridgeConfig = {
|
||||||
|
...devConfig,
|
||||||
|
authMode: 'token',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Bridge MVP Routes', () => {
|
||||||
|
// ── Health ─────────────────────────────────────────────────
|
||||||
|
describe('GET /bridge/v1/health', () => {
|
||||||
|
it('returns health status', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app).get('/bridge/v1/health').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data.status).toBe('ok');
|
||||||
|
expect(res.body.data.version).toBe(VERSION);
|
||||||
|
expect(res.body.data.bridge.configured).toBe(true);
|
||||||
|
expect(res.body.data.bridge.reachable).toBe(true);
|
||||||
|
expect(res.body.requestId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is accessible without auth', async () => {
|
||||||
|
const app = createApp(tokenConfig);
|
||||||
|
// No token header — should still succeed
|
||||||
|
const res = await request(app).get('/bridge/v1/health').expect(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Boards ─────────────────────────────────────────────────
|
||||||
|
describe('GET /bridge/v1/boards', () => {
|
||||||
|
it('returns board list in dev mode', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app).get('/bridge/v1/boards').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data).toHaveLength(2);
|
||||||
|
expect(res.body.data[0].slug).toBe('default');
|
||||||
|
expect(res.body.data[1].slug).toBe('backend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires auth in token mode', async () => {
|
||||||
|
const app = createApp(tokenConfig);
|
||||||
|
await request(app).get('/bridge/v1/boards').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid token', async () => {
|
||||||
|
const app = createApp(tokenConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/bridge/v1/boards')
|
||||||
|
.set('X-Bridge-Token', 'test-token')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tasks ──────────────────────────────────────────────────
|
||||||
|
describe('GET /bridge/v1/boards/:board/tasks', () => {
|
||||||
|
it('returns task list', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/bridge/v1/boards/default/tasks')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data).toHaveLength(2);
|
||||||
|
expect(res.body.data[0].id).toBe('T-001');
|
||||||
|
expect(res.body.data[1].status).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards query params', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
await request(app)
|
||||||
|
.get('/bridge/v1/boards/default/tasks?status=todo&limit=10')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// The mock was called; verify the shape is ok
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disallowed boards', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/bridge/v1/boards/secret/tasks')
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /bridge/v1/boards/:board/tasks/:taskId', () => {
|
||||||
|
it('returns task detail', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/bridge/v1/boards/default/tasks/T-001')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data.id).toBe('T-001');
|
||||||
|
expect(res.body.data.body).toBeDefined();
|
||||||
|
expect(res.body.data.comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for missing task', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/bridge/v1/boards/default/tasks/T-999')
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create task ────────────────────────────────────────────
|
||||||
|
describe('POST /bridge/v1/boards/:board/tasks', () => {
|
||||||
|
it('creates a task', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks')
|
||||||
|
.send({ title: 'New task from API' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data.id).toBe('T-003');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing title', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks')
|
||||||
|
.send({ body: 'no title' })
|
||||||
|
.expect(422);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty title', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks')
|
||||||
|
.send({ title: ' ' })
|
||||||
|
.expect(422);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add comment ────────────────────────────────────────────
|
||||||
|
describe('POST /bridge/v1/boards/:board/tasks/:taskId/comments', () => {
|
||||||
|
it('adds a comment', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||||
|
.send({ body: 'Looks good!' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.data.added).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing body', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||||
|
.send({})
|
||||||
|
.expect(422);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty body', async () => {
|
||||||
|
const app = createApp(devConfig);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||||
|
.send({ body: '' })
|
||||||
|
.expect(422);
|
||||||
|
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// @kanban/bridge — entry point
|
// @kanban/bridge — Express HTTP server (M2 Bridge MVP)
|
||||||
// Self-hosted Hermes Kanban Bridge (Node.js)
|
// Self-hosted Hermes Kanban Bridge (Node.js)
|
||||||
// ADR §5: "runs on trusted host near Hermes"
|
// ADR §5: "runs on trusted host near Hermes"
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import type { HealthData } from '@kanban/contracts';
|
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
HealthData,
|
||||||
|
Board,
|
||||||
|
TaskSummary,
|
||||||
|
TaskDetail,
|
||||||
|
ApiResponse,
|
||||||
|
ErrorCode,
|
||||||
|
} from '@kanban/contracts';
|
||||||
|
import {
|
||||||
|
HermesKanbanClient,
|
||||||
|
type HermesClientConfig,
|
||||||
|
} from './hermes-client.js';
|
||||||
|
import { validateInitData, createDevUser, type DevUser } from './auth.js';
|
||||||
|
|
||||||
// ─── Configuration ───────────────────────────────────────────
|
// ─── Configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -17,30 +31,145 @@ export interface BridgeConfig {
|
||||||
commandTimeoutSeconds: number;
|
commandTimeoutSeconds: number;
|
||||||
allowedBoards: string[];
|
allowedBoards: string[];
|
||||||
logTailMaxLines: number;
|
logTailMaxLines: number;
|
||||||
|
/** 'token' = validate X-Bridge-Token; 'dev' = bypass auth */
|
||||||
|
authMode: 'token' | 'dev';
|
||||||
|
/** Telegram bot token for initData validation (optional) */
|
||||||
|
botToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): BridgeConfig {
|
export function loadConfig(): BridgeConfig {
|
||||||
return {
|
return {
|
||||||
host: process.env.BRIDGE_HOST ?? '127.0.0.1',
|
host: process.env.BRIDGE_HOST ?? '127.0.0.1',
|
||||||
port: parseInt(process.env.BRIDGE_PORT ?? '8787', 10),
|
port: parseInt(process.env.BRIDGE_PORT ?? '8787', 10),
|
||||||
bridgeToken: requireEnv('BRIDGE_TOKEN'),
|
bridgeToken: process.env.BRIDGE_TOKEN ?? '',
|
||||||
hermesHome: process.env.HERMES_HOME ?? '',
|
hermesHome: process.env.HERMES_HOME ?? '',
|
||||||
hermesBin: process.env.HERMES_BIN ?? 'hermes',
|
hermesBin: process.env.HERMES_BIN ?? 'hermes',
|
||||||
commandTimeoutSeconds: parseInt(process.env.COMMAND_TIMEOUT_SECONDS ?? '30', 10),
|
commandTimeoutSeconds: parseInt(
|
||||||
|
process.env.COMMAND_TIMEOUT_SECONDS ?? '30',
|
||||||
|
10
|
||||||
|
),
|
||||||
allowedBoards: (process.env.BRIDGE_ALLOWED_BOARDS ?? 'default')
|
allowedBoards: (process.env.BRIDGE_ALLOWED_BOARDS ?? 'default')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
logTailMaxLines: parseInt(process.env.LOG_TAIL_MAX_LINES ?? '500', 10),
|
logTailMaxLines: parseInt(process.env.LOG_TAIL_MAX_LINES ?? '500', 10),
|
||||||
|
authMode: (process.env.AUTH_MODE as 'token' | 'dev') ?? 'token',
|
||||||
|
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
const value = process.env[name];
|
|
||||||
if (!value) {
|
function ok<T>(data: T, requestId: string): ApiResponse<T> {
|
||||||
throw new Error(`Missing required environment variable: ${name}`);
|
return { ok: true, data, requestId };
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
|
function err(
|
||||||
|
code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
requestId: string,
|
||||||
|
details?: unknown
|
||||||
|
): ApiResponse<never> {
|
||||||
|
return { ok: false, error: { code, message, details }, requestId } as ApiResponse<never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extend Express Request ──────────────────────────────────
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
requestId: string;
|
||||||
|
actor?: DevUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Request ID middleware ────────────────────────────────────
|
||||||
|
|
||||||
|
function requestIdMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||||
|
req.requestId =
|
||||||
|
(req.headers['x-request-id'] as string) ?? randomUUID();
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth middleware ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function createAuthMiddleware(config: BridgeConfig) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// Health endpoint is always public
|
||||||
|
if (req.path === '/bridge/v1/health') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.authMode === 'dev') {
|
||||||
|
// Dev mode: inject a fake user
|
||||||
|
req.actor = createDevUser();
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token mode: validate X-Bridge-Token
|
||||||
|
const token = req.headers['x-bridge-token'] as string | undefined;
|
||||||
|
if (!token || token !== config.bridgeToken) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json(err('UNAUTHORIZED', 'Missing or invalid bridge token', req.requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally validate Telegram initData from X-Telegram-Init-Data header
|
||||||
|
const initData = req.headers['x-telegram-init-data'] as string | undefined;
|
||||||
|
if (initData && config.botToken) {
|
||||||
|
const result = validateInitData(initData, { botToken: config.botToken });
|
||||||
|
if (!result.valid) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json(err('UNAUTHORIZED', `Invalid initData: ${result.error}`, req.requestId));
|
||||||
|
}
|
||||||
|
req.actor = {
|
||||||
|
telegramId: result.user?.id ?? 0,
|
||||||
|
firstName: result.user?.first_name ?? 'Unknown',
|
||||||
|
role: 'operator',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Board allowlist middleware ───────────────────────────────
|
||||||
|
|
||||||
|
function boardAllowlistMiddleware(config: BridgeConfig) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const board = req.params.board;
|
||||||
|
if (board && config.allowedBoards.length > 0 && !config.allowedBoards.includes('*')) {
|
||||||
|
if (!config.allowedBoards.includes(board)) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json(
|
||||||
|
err(
|
||||||
|
'FORBIDDEN',
|
||||||
|
`Board '${board}' is not in the allowed list`,
|
||||||
|
req.requestId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Error handler ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.error(`[bridge] Unhandled error on ${req.method} ${req.path}:`, message);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json(
|
||||||
|
(function () {
|
||||||
|
const code: ErrorCode = 'INTERNAL_ERROR';
|
||||||
|
return { ok: false as const, error: { code, message }, requestId: req.requestId };
|
||||||
|
})()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Health check ────────────────────────────────────────────
|
// ─── Health check ────────────────────────────────────────────
|
||||||
|
|
@ -56,39 +185,180 @@ export function getHealthData(version: string): HealthData {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Server skeleton ─────────────────────────────────────────
|
// ─── Build Express app ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
export function createApp(config: BridgeConfig): express.Express {
|
||||||
* Placeholder for the actual HTTP server (Express / Fastify / raw http).
|
const app = express();
|
||||||
* M2 will implement real endpoints.
|
|
||||||
*
|
// Parse JSON bodies
|
||||||
* Route map (ADR §10):
|
app.use(express.json({ limit: '1mb' }));
|
||||||
* GET /bridge/v1/health
|
|
||||||
* GET /bridge/v1/boards
|
// Request ID
|
||||||
* GET /bridge/v1/boards/:board/tasks
|
app.use(requestIdMiddleware);
|
||||||
* GET /bridge/v1/boards/:board/tasks/:taskId
|
|
||||||
* GET /bridge/v1/boards/:board/tasks/:taskId/log
|
// Auth
|
||||||
* POST /bridge/v1/boards/:board/tasks
|
app.use(createAuthMiddleware(config));
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/comment
|
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/assign
|
// ── Health ───────────────────────────────────────────────
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/promote
|
app.get('/bridge/v1/health', (_req: Request, res: Response) => {
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/block
|
res.json(ok(getHealthData(VERSION), randomUUID()));
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/unblock
|
});
|
||||||
* POST /bridge/v1/boards/:board/tasks/:taskId/archive
|
|
||||||
* POST /bridge/v1/boards/:board/dispatch
|
// ── Board routes ─────────────────────────────────────────
|
||||||
*/
|
const boardRouter = express.Router({ mergeParams: true });
|
||||||
export async function startBridge(config: BridgeConfig): Promise<void> {
|
|
||||||
// M2: implement HTTP server with auth middleware
|
// Board allowlist check on all /boards/:board routes
|
||||||
console.log(
|
boardRouter.use(boardAllowlistMiddleware(config));
|
||||||
`[bridge] Skeleton loaded. Would listen on ${config.host}:${config.port}`
|
|
||||||
|
// GET /bridge/v1/boards
|
||||||
|
app.get('/bridge/v1/boards', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = getClient(config);
|
||||||
|
const boards = await client.listBoards();
|
||||||
|
res.json(ok<Board[]>(boards, req.requestId));
|
||||||
|
} catch (e) {
|
||||||
|
handleCliError(e, req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /bridge/v1/boards/:board/tasks
|
||||||
|
boardRouter.get('/tasks', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = getClient(config);
|
||||||
|
const { status, assignee, limit } = req.query;
|
||||||
|
const tasks = await client.listTasks(req.params.board, {
|
||||||
|
status: status as string | undefined,
|
||||||
|
assignee: assignee as string | undefined,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
res.json(ok<TaskSummary[]>(tasks, req.requestId));
|
||||||
|
} catch (e) {
|
||||||
|
handleCliError(e, req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /bridge/v1/boards/:board/tasks/:taskId
|
||||||
|
boardRouter.get('/tasks/:taskId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const client = getClient(config);
|
||||||
|
const task = await client.getTask(req.params.board, req.params.taskId);
|
||||||
|
res.json(ok<TaskDetail>(task, req.requestId));
|
||||||
|
} catch (e) {
|
||||||
|
handleCliError(e, req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /bridge/v1/boards/:board/tasks
|
||||||
|
boardRouter.post('/tasks', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, body: taskBody, assignee, priority } = req.body ?? {};
|
||||||
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
|
return res
|
||||||
|
.status(422)
|
||||||
|
.json(err('VALIDATION_ERROR', 'title is required', req.requestId));
|
||||||
|
}
|
||||||
|
const client = getClient(config);
|
||||||
|
const task = await client.createTask(req.params.board, {
|
||||||
|
title: title.trim(),
|
||||||
|
body: taskBody,
|
||||||
|
assignee,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
res.status(201).json(ok<TaskDetail>(task, req.requestId));
|
||||||
|
} catch (e) {
|
||||||
|
handleCliError(e, req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /bridge/v1/boards/:board/tasks/:taskId/comments
|
||||||
|
boardRouter.post('/tasks/:taskId/comments', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { body: commentBody } = req.body ?? {};
|
||||||
|
if (
|
||||||
|
!commentBody ||
|
||||||
|
typeof commentBody !== 'string' ||
|
||||||
|
commentBody.trim().length === 0
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(422)
|
||||||
|
.json(err('VALIDATION_ERROR', 'body is required', req.requestId));
|
||||||
|
}
|
||||||
|
const client = getClient(config);
|
||||||
|
await client.addComment(
|
||||||
|
req.params.board,
|
||||||
|
req.params.taskId,
|
||||||
|
commentBody.trim()
|
||||||
);
|
);
|
||||||
|
res.json(ok({ added: true }, req.requestId));
|
||||||
|
} catch (e) {
|
||||||
|
handleCliError(e, req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount board router
|
||||||
|
app.use('/bridge/v1/boards/:board', boardRouter);
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CLI error mapping ───────────────────────────────────────
|
||||||
|
|
||||||
|
function handleCliError(e: unknown, req: Request, res: Response) {
|
||||||
|
const message = e instanceof Error ? e.message : 'Unknown bridge error';
|
||||||
|
if (message.includes('not found') || message.includes('No such task')) {
|
||||||
|
res.status(404).json(err('NOT_FOUND', message, req.requestId));
|
||||||
|
} else if (message.includes('timeout') || message.includes('TIMEOUT')) {
|
||||||
|
res.status(504).json(err('TIMEOUT', message, req.requestId));
|
||||||
|
} else {
|
||||||
|
res.status(502).json(err('BRIDGE_ERROR', message, req.requestId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Singleton client ────────────────────────────────────────
|
||||||
|
|
||||||
|
let _client: HermesKanbanClient | null = null;
|
||||||
|
|
||||||
|
function getClient(config: BridgeConfig): HermesKanbanClient {
|
||||||
|
if (!_client) {
|
||||||
|
const clientConfig: HermesClientConfig = {
|
||||||
|
bin: config.hermesBin,
|
||||||
|
home: config.hermesHome,
|
||||||
|
defaultBoard: config.allowedBoards[0] ?? 'default',
|
||||||
|
timeoutMs: config.commandTimeoutSeconds * 1000,
|
||||||
|
};
|
||||||
|
_client = new HermesKanbanClient(clientConfig);
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Server lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function startBridge(config: BridgeConfig): Promise<ReturnType<typeof express.application.listen>> {
|
||||||
|
const app = createApp(config);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = app.listen(config.port, config.host, () => {
|
||||||
|
console.log(
|
||||||
|
`[bridge] v${VERSION} listening on http://${config.host}:${config.port}`
|
||||||
|
);
|
||||||
|
console.log(`[bridge] Auth mode: ${config.authMode}`);
|
||||||
|
resolve(server);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main ────────────────────────────────────────────────────
|
// ─── Main ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const VERSION = '0.1.0';
|
export const VERSION = '0.2.0';
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
// Only start server when run directly
|
||||||
|
if (
|
||||||
|
typeof process !== 'undefined' &&
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === `file://${process.argv[1]}`
|
||||||
|
) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
console.log(`[bridge] v${VERSION} starting...`);
|
console.log(`[bridge] v${VERSION} starting...`);
|
||||||
await startBridge(config);
|
await startBridge(config);
|
||||||
|
|
|
||||||
9
packages/bridge/vitest.config.ts
Normal file
9
packages/bridge/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
globals: false,
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
1816
pnpm-lock.yaml
generated
1816
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue