managarten/services/mana-research/src/index.ts
Till JS 7d120225dc feat(research): Phase 3b openai-deep-research async + BYO-keys CRUD & UI
Two backlog items landed in one commit because an earlier amend in a
parallel terminal dropped the initial Phase 3b commit and the BYO-keys
work was blocked on the same wiring.

openai-deep-research (async):
- New research.async_jobs table persists the OpenAI response.id, query,
  reservation, and cached result/error.
- POST /v1/research/async reserves credits, submits to the Responses API
  with background=true, returns a taskId. Submit failure refunds.
- GET /v1/research/async/:taskId polls upstream, commits the reservation
  on completion, refunds on failure, short-circuits for terminal states.
- GET /v1/research/async lists the user's async tasks.

BYO-keys:
- research.provider_configs CRUD at /v1/provider-configs. Keys are masked
  (••••last4) on read so the raw secret never re-transits to the browser.
  Currently stored plaintext with a TODO for AES-GCM-256 via the shared
  KEK — single call site in storage/configs.ts.decryptKey().
- New frontend route /research-lab/keys lets the user paste a key per
  provider, toggle enabled, and set daily/monthly credit budgets.
- ListView grew a 🔑 link in the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:43:12 +02:00

114 lines
4.1 KiB
TypeScript

/**
* mana-research — Web Research Provider Orchestration
*
* Bundles search/extract/agent providers behind a unified interface.
* Phase 1: 4 search providers (SearXNG, DuckDuckGo, Brave, Tavily) with
* credits + cache + eval-run persistence.
*
* Port: 3068. See docs/plans/mana-research-service.md.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { serviceErrorHandler } from '@mana/shared-hono';
import { jwtAuth } from './middleware/jwt-auth';
import { serviceAuth } from './middleware/service-auth';
import { healthRoutes } from './routes/health';
import { createSearchRoutes } from './routes/search';
import { createExtractRoutes } from './routes/extract';
import { createResearchRoutes } from './routes/research';
import { createProvidersRoutes } from './routes/providers';
import { createRunsRoutes } from './routes/runs';
import { createProviderConfigRoutes } from './routes/provider-configs';
import { buildRegistry } from './providers/registry';
import { RunStorage } from './storage/runs';
import { ConfigStorage } from './storage/configs';
import { AsyncJobStorage } from './storage/async-jobs';
import { CreditsClient } from './clients/mana-credits';
import { ManaSearchClient } from './clients/mana-search';
import { ManaLlmClient } from './clients/mana-llm';
import { initCache } from './lib/cache';
// ─── Bootstrap ──────────────────────────────────────────────
const config = loadConfig();
const db = getDb(config.databaseUrl);
initCache(config.redisUrl);
const manaSearch = new ManaSearchClient(config.manaSearchUrl);
const manaLlm = new ManaLlmClient(config.manaLlmUrl);
const credits = new CreditsClient({
baseUrl: config.manaCreditsUrl,
serviceKey: config.serviceKey,
});
const runStorage = new RunStorage(db);
const configStorage = new ConfigStorage(db);
const asyncStorage = new AsyncJobStorage(db);
const registry = buildRegistry({ manaSearch });
const executorDeps = {
credits,
configs: configStorage,
config,
};
// ─── App ────────────────────────────────────────────────────
const app = new Hono();
app.onError(serviceErrorHandler);
app.use(
'*',
cors({
origin: config.cors.origins,
credentials: true,
})
);
// Health (no auth)
app.route('/health', healthRoutes);
// Metrics stub (no auth) — will be populated in Phase 2 with prometheus-style output
app.get('/metrics', (c) => c.text('# mana-research metrics stub\n'));
// Providers catalog (no auth — callers often query this to build UIs)
app.route('/api/v1/providers', createProvidersRoutes(registry, config));
// User-facing research (JWT auth)
app.use('/api/v1/search/*', jwtAuth(config.manaAuthUrl));
app.route(
'/api/v1/search',
createSearchRoutes(registry, runStorage, executorDeps, config, manaLlm)
);
app.use('/api/v1/extract/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/extract', createExtractRoutes(registry, runStorage, executorDeps, config));
app.use('/api/v1/research/*', jwtAuth(config.manaAuthUrl));
app.route(
'/api/v1/research',
createResearchRoutes(registry, runStorage, executorDeps, config, asyncStorage, credits)
);
app.use('/api/v1/runs/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/runs', createRunsRoutes(runStorage));
app.use('/api/v1/provider-configs/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/provider-configs', createProviderConfigRoutes(db));
// Service-to-service (X-Service-Key auth) — wired up in Phase 3 when mana-ai migrates
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
app.get('/api/v1/internal/health', (c) => c.json({ ok: true }));
// ─── Start ──────────────────────────────────────────────────
console.log(`mana-research starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};