feat(bridge): M2 Bridge MVP with HTTP endpoints
Some checks failed
CI / backend (push) Failing after 45s
CI / frontend (push) Failing after 44s

This commit is contained in:
kisskin 2026-07-10 10:20:28 +03:00
parent 0042fb3df0
commit dbf290e49a
5 changed files with 2422 additions and 39 deletions

View file

@ -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"
} }
} }

View 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);
});
});
});

View file

@ -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);

View 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

File diff suppressed because it is too large Load diff