import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { createHmac } from 'node:crypto'; import { connectorsForUrl, createRouter, createCrawlContext, listConnectorMeta } from '@rareindex/connectors'; import type { NormalizedRecord } from '@rareindex/shared'; import { problem } from '../lib/envelope.js'; /** * Internal (loopback-only) endpoints used by the web app. Connector modules are loaded dynamically * (`import(path)`), which the Next.js bundler cannot do — so URL lookups run here, where tsx can. * Auth: `x-internal-token` = HMAC(SESSION_SECRET, "internal") — both processes share SESSION_SECRET. */ export function internalToken(secret = process.env.SESSION_SECRET ?? 'dev-only'): string { return createHmac('sha256', secret).update('internal').digest('hex'); } export async function internalRoutes(app: FastifyInstance) { app.addHook('onRequest', async (req, reply) => { if (!req.url.startsWith('/internal/')) return; const loopback = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1'; const ok = loopback && req.headers['x-internal-token'] === internalToken(); if (!ok) return problem(reply, 403, 'Forbidden', 'Internal endpoint'); }); app.get('/internal/lookup/sources', async () => ({ data: listConnectorMeta({ enabled: true }).filter((m) => m.supportsLookup).map((m) => ({ id: m.id, sourceName: m.sourceName, sourceUrl: m.sourceUrl })) })); app.post('/internal/lookup', async (req, reply) => { const body = z.object({ url: z.string().url().max(2000) }).safeParse(req.body); if (!body.success) return problem(reply, 400, 'Invalid body', 'url required'); const url = body.data.url; const connectors = await connectorsForUrl(url); const connector = connectors[0]; if (!connector?.lookup) return { data: null, meta: { supported: false } }; const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: 1 } }); const started = Date.now(); const raws = await connector.lookup(url, ctx); const records: NormalizedRecord[] = []; for (const raw of raws) records.push(...(await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }))); return { data: { connectorId: connector.meta.id, sourceId: connector.meta.sourceId, records }, meta: { supported: true, durationMs: Date.now() - started, engineStats: ctx.engineStats } }; }); }