diff --git a/products/04-lightweight-idp/src/api/discovery.ts b/products/04-lightweight-idp/src/api/discovery.ts new file mode 100644 index 0000000..800c9d8 --- /dev/null +++ b/products/04-lightweight-idp/src/api/discovery.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from 'fastify'; +import pino from 'pino'; +import { withTenant } from '../data/db.js'; + +const logger = pino({ name: 'api-discovery' }); + +export function registerDiscoveryRoutes(app: FastifyInstance) { + // Trigger AWS discovery scan + app.post('/api/v1/discovery/aws', async (req, reply) => { + const tenantId = (req as any).tenantId; + // TODO: Enqueue scan job (Redis queue or direct execution) + logger.info({ tenantId }, 'AWS discovery scan triggered'); + return reply.status(202).send({ status: 'scan_queued', scanner: 'aws' }); + }); + + // Trigger GitHub discovery scan + app.post('/api/v1/discovery/github', async (req, reply) => { + const tenantId = (req as any).tenantId; + logger.info({ tenantId }, 'GitHub discovery scan triggered'); + return reply.status(202).send({ status: 'scan_queued', scanner: 'github' }); + }); + + // Get scan history + app.get('/api/v1/discovery/history', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query('SELECT * FROM scan_history ORDER BY started_at DESC LIMIT 20'); + }); + + return { scans: result.rows }; + }); + + // List staged updates (from partial scans) + app.get('/api/v1/discovery/staged', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query("SELECT * FROM staged_updates WHERE status = 'pending' ORDER BY created_at DESC"); + }); + + return { staged: result.rows }; + }); + + // Apply or reject staged update + app.post('/api/v1/discovery/staged/:id/:action', async (req, reply) => { + const { id, action } = req.params as { id: string; action: string }; + const tenantId = (req as any).tenantId; + + if (action !== 'apply' && action !== 'reject') { + return reply.status(400).send({ error: 'Action must be apply or reject' }); + } + + const newStatus = action === 'apply' ? 'applied' : 'rejected'; + + await withTenant(tenantId, async (client) => { + await client.query('UPDATE staged_updates SET status = $1 WHERE id = $2', [newStatus, id]); + // TODO: If applied, merge changes into services table + }); + + return { status: newStatus }; + }); +} diff --git a/products/04-lightweight-idp/src/api/search.ts b/products/04-lightweight-idp/src/api/search.ts new file mode 100644 index 0000000..fcfa4c1 --- /dev/null +++ b/products/04-lightweight-idp/src/api/search.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { MeiliSearch } from 'meilisearch'; +import pino from 'pino'; +import { config } from '../config/index.js'; + +const logger = pino({ name: 'api-search' }); + +let meili: MeiliSearch | null = null; + +function getMeili(): MeiliSearch { + if (!meili) { + meili = new MeiliSearch({ host: config.MEILI_URL, apiKey: config.MEILI_KEY || undefined }); + } + return meili; +} + +const searchQuerySchema = z.object({ + q: z.string().min(1).max(500), + limit: z.coerce.number().min(1).max(50).default(20), + offset: z.coerce.number().min(0).default(0), + filter: z.string().optional(), // Meilisearch filter syntax +}); + +export function registerSearchRoutes(app: FastifyInstance) { + // Full-text search across services + app.get('/api/v1/search', async (req, reply) => { + const query = searchQuerySchema.parse(req.query); + const tenantId = (req as any).tenantId; + + try { + const index = getMeili().index(`services_${tenantId}`); + const results = await index.search(query.q, { + limit: query.limit, + offset: query.offset, + filter: query.filter, + attributesToHighlight: ['name', 'description', 'owner'], + }); + + return { + hits: results.hits, + total: results.estimatedTotalHits, + query: query.q, + processingTimeMs: results.processingTimeMs, + }; + } catch (err) { + logger.warn({ error: (err as Error).message }, 'Meilisearch unavailable — falling back to PG'); + + // Fallback: basic ILIKE search on PostgreSQL + const { withTenant } = await import('../data/db.js'); + const result = await withTenant(tenantId, async (client) => { + return client.query( + `SELECT * FROM services WHERE name ILIKE $1 OR description ILIKE $1 OR owner ILIKE $1 LIMIT $2 OFFSET $3`, + [`%${query.q}%`, query.limit, query.offset], + ); + }); + + return { hits: result.rows, total: result.rowCount, query: query.q, fallback: true }; + } + }); + + // Sync services to Meilisearch index + app.post('/api/v1/search/reindex', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const { withTenant } = await import('../data/db.js'); + const services = await withTenant(tenantId, async (client) => { + return client.query('SELECT * FROM services'); + }); + + const index = getMeili().index(`services_${tenantId}`); + await index.addDocuments(services.rows.map(s => ({ ...s, id: s.id }))); + + logger.info({ tenantId, count: services.rowCount }, 'Reindex triggered'); + return { status: 'reindexing', documents: services.rowCount }; + }); +} diff --git a/products/04-lightweight-idp/src/api/services.ts b/products/04-lightweight-idp/src/api/services.ts new file mode 100644 index 0000000..dc3b80d --- /dev/null +++ b/products/04-lightweight-idp/src/api/services.ts @@ -0,0 +1,114 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { withTenant } from '../data/db.js'; + +const listQuerySchema = z.object({ + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), + type: z.string().optional(), + owner: z.string().optional(), + lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).optional(), + tier: z.enum(['critical', 'high', 'medium', 'low']).optional(), +}); + +const upsertServiceSchema = z.object({ + name: z.string().min(1).max(200), + type: z.string().default('unknown'), + owner: z.string().default('unknown'), + description: z.string().max(2000).optional(), + tier: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), + lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).default('active'), + links: z.record(z.string()).default({}), + tags: z.record(z.string()).default({}), +}); + +export function registerServiceRoutes(app: FastifyInstance) { + // List services + app.get('/api/v1/services', async (req, reply) => { + const query = listQuerySchema.parse(req.query); + const tenantId = (req as any).tenantId; + const offset = (query.page - 1) * query.limit; + + const result = await withTenant(tenantId, async (client) => { + let sql = 'SELECT * FROM services WHERE 1=1'; + const params: any[] = []; + let idx = 1; + + if (query.type) { sql += ` AND type = $${idx++}`; params.push(query.type); } + if (query.owner) { sql += ` AND owner = $${idx++}`; params.push(query.owner); } + if (query.lifecycle) { sql += ` AND lifecycle = $${idx++}`; params.push(query.lifecycle); } + if (query.tier) { sql += ` AND tier = $${idx++}`; params.push(query.tier); } + + sql += ` ORDER BY name LIMIT $${idx++} OFFSET $${idx++}`; + params.push(query.limit, offset); + + return client.query(sql, params); + }); + + return { services: result.rows, page: query.page, limit: query.limit }; + }); + + // Get service detail + app.get('/api/v1/services/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query('SELECT * FROM services WHERE id = $1', [id]); + }); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Not found' }); + return { service: result.rows[0] }; + }); + + // Create/update service (manual entry) + app.put('/api/v1/services', async (req, reply) => { + const body = upsertServiceSchema.parse(req.body); + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query( + `INSERT INTO services (tenant_id, name, type, owner, owner_source, description, tier, lifecycle, links, tags) + VALUES ($1, $2, $3, $4, 'config', $5, $6, $7, $8, $9) + ON CONFLICT (tenant_id, name) DO UPDATE SET + type = EXCLUDED.type, owner = EXCLUDED.owner, owner_source = 'config', + description = EXCLUDED.description, tier = EXCLUDED.tier, lifecycle = EXCLUDED.lifecycle, + links = EXCLUDED.links, tags = EXCLUDED.tags, updated_at = now() + RETURNING *`, + [tenantId, body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags)], + ); + }); + + return reply.status(201).send({ service: result.rows[0] }); + }); + + // Delete service + app.delete('/api/v1/services/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const tenantId = (req as any).tenantId; + + await withTenant(tenantId, async (client) => { + await client.query('DELETE FROM services WHERE id = $1', [id]); + }); + + return { status: 'deleted' }; + }); + + // Ownership summary (who owns what) + app.get('/api/v1/ownership', async (req, reply) => { + const tenantId = (req as any).tenantId; + + const result = await withTenant(tenantId, async (client) => { + return client.query(` + SELECT owner, owner_source, COUNT(*)::int as service_count, + array_agg(name ORDER BY name) as services + FROM services + WHERE lifecycle = 'active' + GROUP BY owner, owner_source + ORDER BY service_count DESC + `); + }); + + return { owners: result.rows }; + }); +} diff --git a/products/04-lightweight-idp/src/config/index.ts b/products/04-lightweight-idp/src/config/index.ts new file mode 100644 index 0000000..9ef5ed6 --- /dev/null +++ b/products/04-lightweight-idp/src/config/index.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.coerce.number().default(3000), + DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_portal'), + REDIS_URL: z.string().default('redis://localhost:6379'), + MEILI_URL: z.string().default('http://localhost:7700'), + MEILI_KEY: z.string().default(''), + JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'), + CORS_ORIGIN: z.string().default('*'), + LOG_LEVEL: z.string().default('info'), +}); + +export const config = envSchema.parse(process.env); +export type Config = z.infer; diff --git a/products/04-lightweight-idp/src/data/db.ts b/products/04-lightweight-idp/src/data/db.ts new file mode 100644 index 0000000..85cd7f5 --- /dev/null +++ b/products/04-lightweight-idp/src/data/db.ts @@ -0,0 +1,24 @@ +import pg from 'pg'; +import pino from 'pino'; +import { config } from '../config/index.js'; + +const logger = pino({ name: 'data' }); + +export const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); + +export async function withTenant(tenantId: string, fn: (client: pg.PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + await client.query('RESET app.tenant_id'); + client.release(); + } +} diff --git a/products/04-lightweight-idp/src/index.ts b/products/04-lightweight-idp/src/index.ts new file mode 100644 index 0000000..3a28ce4 --- /dev/null +++ b/products/04-lightweight-idp/src/index.ts @@ -0,0 +1,29 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import pino from 'pino'; +import { config } from './config/index.js'; +import { registerServiceRoutes } from './api/services.js'; +import { registerDiscoveryRoutes } from './api/discovery.js'; +import { registerSearchRoutes } from './api/search.js'; + +const logger = pino({ name: 'dd0c-portal', level: config.LOG_LEVEL }); + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: config.CORS_ORIGIN }); +await app.register(helmet); + +app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' })); + +registerServiceRoutes(app); +registerDiscoveryRoutes(app); +registerSearchRoutes(app); + +try { + await app.listen({ port: config.PORT, host: '0.0.0.0' }); + logger.info({ port: config.PORT }, 'dd0c/portal started'); +} catch (err) { + logger.fatal(err, 'Failed to start'); + process.exit(1); +}