mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* refactor(daemon): introduce HTTP Request Adapter + typed Deps (proof on active-context-routes)
Adds a typed HTTP boundary Adapter under apps/daemon/src/http/ that replaces
the untyped ServerContext service-locator pattern (30+ fields, mostly any)
for route handlers. Routes become pure (input, deps) -> Result<output>
functions, unit-testable without Express or supertest.
Six new modules under apps/daemon/src/http/:
- types.ts Result<T,E>, ok(), err(), JsonRouteSpec, Handler,
RouteInputContext, HttpMethod, InputParser
- parse.ts rawInput(req), validationError(message, issues?)
- response.ts sendJson(), sendApiError(), statusForError() +
ERROR_STATUS_BY_CODE map
- origin-guard.ts guardSameOrigin(req, origin) wrapping isLocalSameOrigin
as a Result
- adapter.ts defineJsonRoute(), mountJsonRoute() (only place that
knows about req/res)
- index.ts barrel
active-context-routes.ts migrated as proof of pattern. parsePostActive(),
handlePostActive(), handleGetActive() are now pure functions; postActiveRoute
and getActiveRoute are exported route specs. The wire signature
registerActiveContextRoutes(app, ctx) is preserved so server.ts is untouched.
Spec at specs/current/daemon-http-adapter.md captures the strangler migration
order for the remaining route files (mcp-routes, chat-routes, artifact
routes, etc.) and a StreamRoute follow-up where the Run Orchestrator lands.
Wire-format note: cross-origin response moves from the legacy
{ error: 'cross-origin request rejected' } shape to the structured
{ error: { code: 'FORBIDDEN', message: ... } } shape. Backwards-compatible
via the existing CompatibleErrorResponse = ApiErrorResponse | LegacyErrorResponse
union in @open-design/contracts.
Validation:
- pnpm install (post-rebase, exit 0)
- pnpm --filter @open-design/daemon typecheck (both tsconfig.json and
tsconfig.tests.json silent => pass)
- pnpm --filter @open-design/daemon test: 15 new tests pass
(tests/http/adapter.test.ts + tests/active-context-routes.test.ts).
84 pre-existing failures across 23 files are unchanged and unrelated
to this PR (Windows symlink / short-name / colon-in-filename, upstream
behavior drift, missing plugin marketplace fixtures, and a freshly-
added tools-connectors-cli suite of 38 failures that landed during
the rebase).
Sharpens W4/W5 of specs/current/maintainability-roadmap.md and unlocks
W6 (Run Orchestrator).
* chore: add core-js, electron-winstaller, protobufjs, sharp to pnpm.onlyBuiltDependencies
147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createApiError } from '@open-design/contracts';
|
|
import { defineJsonRoute, err, mountJsonRoute, ok } from '../../src/http/index.js';
|
|
import { isLocalSameOrigin } from '../../src/origin-validation.js';
|
|
|
|
vi.mock('../../src/origin-validation.js', () => ({
|
|
isLocalSameOrigin: vi.fn(() => true),
|
|
}));
|
|
|
|
interface MockApp {
|
|
get: (path: string, handler: any) => void;
|
|
post: (path: string, handler: any) => void;
|
|
put: (path: string, handler: any) => void;
|
|
delete: (path: string, handler: any) => void;
|
|
patch: (path: string, handler: any) => void;
|
|
handlers: Record<string, (req: any, res: any) => Promise<void> | void>;
|
|
}
|
|
|
|
function makeApp(): MockApp {
|
|
const handlers: MockApp['handlers'] = {};
|
|
const make = (method: string) => (path: string, handler: any) => {
|
|
handlers[`${method.toUpperCase()} ${path}`] = handler;
|
|
};
|
|
return {
|
|
get: make('get'),
|
|
post: make('post'),
|
|
put: make('put'),
|
|
delete: make('delete'),
|
|
patch: make('patch'),
|
|
handlers,
|
|
};
|
|
}
|
|
|
|
function makeRes() {
|
|
return {
|
|
status: vi.fn().mockReturnThis(),
|
|
json: vi.fn().mockReturnThis(),
|
|
};
|
|
}
|
|
|
|
const adapter = { resolvedPortRef: { current: 7456 } };
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(isLocalSameOrigin).mockReturnValue(true);
|
|
});
|
|
|
|
describe('http adapter', () => {
|
|
it('parses input and returns the success payload', async () => {
|
|
const route = defineJsonRoute<{ value: string }, { echoed: string }, unknown>({
|
|
method: 'post',
|
|
path: '/echo',
|
|
parse: (raw) => ok({ value: String((raw.body as any).value) }),
|
|
handle: (input) => ok({ echoed: input.value }),
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, {}, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['POST /echo']!({ body: { value: 'hi' }, query: {}, params: {} }, res);
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({ echoed: 'hi' });
|
|
});
|
|
|
|
it('returns 400 when parse fails', async () => {
|
|
const route = defineJsonRoute<{ value: string }, unknown, unknown>({
|
|
method: 'post',
|
|
path: '/missing',
|
|
parse: () => err(createApiError('BAD_REQUEST', 'required')),
|
|
handle: () => ok({}),
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, {}, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['POST /missing']!({ body: {}, query: {}, params: {} }, res);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({ error: { code: 'BAD_REQUEST', message: 'required' } });
|
|
});
|
|
|
|
it('maps a NOT_FOUND domain error to 404', async () => {
|
|
const route = defineJsonRoute<void, unknown, unknown>({
|
|
method: 'get',
|
|
path: '/missing',
|
|
parse: () => ok(undefined),
|
|
handle: () => err(createApiError('NOT_FOUND', 'gone')),
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, {}, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['GET /missing']!({ body: {}, query: {}, params: {} }, res);
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith({ error: { code: 'NOT_FOUND', message: 'gone' } });
|
|
});
|
|
|
|
it('blocks cross-origin requests when requireSameOrigin is set', async () => {
|
|
vi.mocked(isLocalSameOrigin).mockReturnValue(false);
|
|
const route = defineJsonRoute<void, { secret: number }, unknown>({
|
|
method: 'get',
|
|
path: '/secret',
|
|
requireSameOrigin: true,
|
|
parse: () => ok(undefined),
|
|
handle: () => ok({ secret: 42 }),
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, {}, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['GET /secret']!({ body: {}, query: {}, params: {} }, res);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: { code: 'FORBIDDEN', message: 'cross-origin request rejected' },
|
|
});
|
|
});
|
|
|
|
it('catches thrown handler errors as INTERNAL_ERROR (500)', async () => {
|
|
const route = defineJsonRoute<void, unknown, unknown>({
|
|
method: 'get',
|
|
path: '/boom',
|
|
parse: () => ok(undefined),
|
|
handle: () => {
|
|
throw new Error('boom');
|
|
},
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, {}, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['GET /boom']!({ body: {}, query: {}, params: {} }, res);
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
error: { code: 'INTERNAL_ERROR', message: 'boom' },
|
|
});
|
|
});
|
|
|
|
it('passes deps through to the handler', async () => {
|
|
interface Deps {
|
|
tag: string;
|
|
}
|
|
const route = defineJsonRoute<void, { tag: string }, Deps>({
|
|
method: 'get',
|
|
path: '/deps',
|
|
parse: () => ok(undefined),
|
|
handle: (_input, deps) => ok({ tag: deps.tag }),
|
|
});
|
|
const app = makeApp();
|
|
mountJsonRoute(app as any, route, { tag: 'injected' }, adapter);
|
|
const res = makeRes();
|
|
await app.handlers['GET /deps']!({ body: {}, query: {}, params: {} }, res);
|
|
expect(res.json).toHaveBeenCalledWith({ tag: 'injected' });
|
|
});
|
|
});
|