diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ea2009 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Environment secrets +.env +.env.local +.env.production + +# Node +node_modules/ +dist/ + +# OS +.DS_Store +Thumbs.db diff --git a/products/.env.example b/products/.env.example index 04b9e00..5ddc947 100644 --- a/products/.env.example +++ b/products/.env.example @@ -1,37 +1,28 @@ -# dd0c Environment Variables -# Copy to .env and fill in your values +# dd0c Environment Configuration +# Copy to .env and fill in real values before deploying. +# NEVER commit .env to git. -# --- Shared --- -DATABASE_URL=postgresql://dd0c:dd0c-dev@localhost:5432/dd0c_alert -REDIS_URL=redis://localhost:6379 -JWT_SECRET=change-me-to-a-real-secret-at-least-32-chars -CORS_ORIGIN=* +# --- Postgres --- +POSTGRES_USER=dd0c +POSTGRES_PASSWORD=change-me-in-production + +# --- Per-service DB credentials (created by docker-init-db.sh) --- +DB_DRIFT_PASSWORD=change-me-drift +DB_ALERT_PASSWORD=change-me-alert +DB_PORTAL_PASSWORD=change-me-portal +DB_COST_PASSWORD=change-me-cost +DB_RUN_PASSWORD=change-me-run + +# --- Auth --- +JWT_SECRET=change-me-generate-with-openssl-rand-base64-32 + +# --- Registry --- +REGISTRY=reg.dd0c.net +REGISTRY_PASSWORD=change-me-registry + +# --- CORS (comma-separated origins) --- +CORS_ORIGIN=http://localhost:5173 + +# --- Optional --- LOG_LEVEL=info -PORT=3000 - -# --- P1: route --- -# OPENAI_API_KEY=sk-... -# ANTHROPIC_API_KEY=sk-ant-... - -# --- P3: alert --- -# DATADOG_WEBHOOK_SECRET=... -# PAGERDUTY_WEBHOOK_SECRET=... -# OPSGENIE_WEBHOOK_SECRET=... - -# --- P4: portal --- -# MEILI_URL=http://localhost:7700 -# MEILI_KEY=... -# GITHUB_TOKEN=ghp_... - -# --- P5: cost --- -# AWS_ACCESS_KEY_ID=... -# AWS_SECRET_ACCESS_KEY=... -# ANOMALY_THRESHOLD=50 - -# --- P6: run --- -# SLACK_BOT_TOKEN=xoxb-... -# SLACK_SIGNING_SECRET=... - -# --- Notifications (shared) --- -# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... -# RESEND_API_KEY=re_... +ANOMALY_THRESHOLD=50 diff --git a/products/02-iac-drift-detection/saas/migrations/003_invites.sql b/products/02-iac-drift-detection/saas/migrations/003_invites.sql new file mode 100644 index 0000000..c4630b0 --- /dev/null +++ b/products/02-iac-drift-detection/saas/migrations/003_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tenant_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')), + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email); diff --git a/products/02-iac-drift-detection/saas/package.json b/products/02-iac-drift-detection/saas/package.json index 16e9b63..2b13412 100644 --- a/products/02-iac-drift-detection/saas/package.json +++ b/products/02-iac-drift-detection/saas/package.json @@ -14,6 +14,7 @@ "dependencies": { "fastify": "^4.28.0", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/helmet": "^11.1.0", "pg": "^8.12.0", "drizzle-orm": "^0.31.0", diff --git a/products/02-iac-drift-detection/saas/src/auth/middleware.ts b/products/02-iac-drift-detection/saas/src/auth/middleware.ts index 321df57..78ecd11 100644 --- a/products/02-iac-drift-detection/saas/src/auth/middleware.ts +++ b/products/02-iac-drift-detection/saas/src/auth/middleware.ts @@ -15,21 +15,11 @@ export interface AuthPayload { } /** - * JWT auth middleware. Extracts tenant context from Bearer token. - * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + * Returns an onRequest hook that validates JWT or API key auth. + * No URL matching — only register this hook inside a protected plugin scope. */ -export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.decorateRequest('tenantId', ''); - app.decorateRequest('userId', ''); - app.decorateRequest('userRole', 'viewer'); - - app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { - if (req.url === '/health' || req.url === '/version') return; - if (req.url.startsWith('/webhooks/')) return; - if (req.url.startsWith('/slack/')) return; - const path = req.url.split('?')[0]; - if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return; - +export function authHook(jwtSecret: string, pool: Pool) { + return async (req: FastifyRequest, reply: FastifyReply) => { const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; @@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool return reply.status(401).send({ error: 'Invalid API key format' }); } - const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars + const prefix = apiKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( @@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, jwtSecret) as AuthPayload; + const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload; (req as any).tenantId = payload.tenantId; (req as any).userId = payload.userId; (req as any).userRole = payload.role; @@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool } return reply.status(401).send({ error: 'Missing authentication' }); - }); + }; +} + +/** + * Decorate the Fastify request with auth properties. + * Call this once on the root app instance before registering any routes. + */ +export function decorateAuth(app: FastifyInstance) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); } export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { @@ -123,11 +123,21 @@ const loginSchema = z.object({ const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), - tenant_name: z.string().min(1).max(100), + tenant_name: z.string().min(1).max(100).optional(), + invite_token: z.string().optional(), +}).refine( + (data) => data.invite_token || data.tenant_name, + { message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] }, +); + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member', 'viewer']).default('member'), }); +/** Public auth routes — login/signup. No auth required. */ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.post('/api/v1/auth/login', async (req, reply) => { + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( @@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool return { token, expires_in: '24h' }; }); - app.post('/api/v1/auth/signup', async (req, reply) => { + app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => { const body = signupSchema.parse(req.body); - // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); - const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); - const tenant = await client.query( - `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, - [body.tenant_name, slug], - ); - const tenantId = tenant.rows[0].id; + let tenantId: string; + let role: string; + + if (body.invite_token) { + const invite = await client.query( + `SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, + [body.invite_token], + ); + if (!invite.rows[0]) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invalid invite token' }); + } + const inv = invite.rows[0]; + if (inv.accepted_at) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite already accepted' }); + } + if (new Date(inv.expires_at) < new Date()) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite expired' }); + } + + tenantId = inv.tenant_id; + role = inv.role; + + await client.query( + `UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`, + [inv.id], + ); + } else { + if (!body.tenant_name) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'tenant_name is required for new signups' }); + } + const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); + const tenant = await client.query( + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + [body.tenant_name, slug], + ); + tenantId = tenant.rows[0].id; + role = 'owner'; + } const user = await client.query( - `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, - [tenantId, body.email, passwordHash], + `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`, + [tenantId, body.email, passwordHash, role], ); await client.query('COMMIT'); @@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool tenantId, userId: user.rows[0].id, email: body.email, - role: 'owner', + role: role as AuthPayload['role'], }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); @@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool client.release(); } }); +} +/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */ +export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { app.get('/api/v1/auth/me', async (req, reply) => { return { tenant_id: (req as any).tenantId, @@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool }; }); - // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; @@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool [tenantId, userId, prefix, keyHash], ); - // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); + + // --- Invite endpoints --- + + app.post('/api/v1/auth/invite', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + const body = inviteSchema.parse(req.body); + + const token = crypto.randomBytes(32).toString('hex'); + const result = await pool.query( + `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING expires_at`, + [tenantId, body.email, body.role, token, userId], + ); + + return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); + }); + + app.get('/api/v1/auth/invites', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + + const result = await pool.query( + `SELECT id, email, role, expires_at, created_at FROM tenant_invites + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [tenantId], + ); + + return { invites: result.rows }; + }); + + app.delete('/api/v1/auth/invites/:id', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const { id } = req.params as { id: string }; + + const result = await pool.query( + `DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`, + [id, tenantId], + ); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' }); + return { deleted: true }; + }); } diff --git a/products/02-iac-drift-detection/saas/src/config/index.ts b/products/02-iac-drift-detection/saas/src/config/index.ts index 239ec52..7a2397a 100644 --- a/products/02-iac-drift-detection/saas/src/config/index.ts +++ b/products/02-iac-drift-detection/saas/src/config/index.ts @@ -6,7 +6,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().default('postgres://dd0c:dd0c@localhost:5432/dd0c_drift'), REDIS_URL: z.string().default('redis://localhost:6379'), JWT_SECRET: z.string().default('dev-secret-change-me'), - CORS_ORIGIN: z.string().default('*'), + CORS_ORIGIN: z.string().default('http://localhost:5173'), LOG_LEVEL: z.string().default('info'), SQS_QUEUE_URL: z.string().optional(), S3_BUCKET: z.string().default('dd0c-drift-snapshots'), diff --git a/products/02-iac-drift-detection/saas/src/data/db.ts b/products/02-iac-drift-detection/saas/src/data/db.ts index 6753403..f83a12d 100644 --- a/products/02-iac-drift-detection/saas/src/data/db.ts +++ b/products/02-iac-drift-detection/saas/src/data/db.ts @@ -15,7 +15,6 @@ export function createPool(connectionString: string): pg.Pool { * MUST be cleared when returning the connection to the pool. */ export async function setTenantContext(client: pg.PoolClient, tenantId: string): Promise { - // SET doesn't support parameterized queries — validate UUID format then interpolate if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error('Invalid tenant ID'); await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); } diff --git a/products/02-iac-drift-detection/saas/src/index.ts b/products/02-iac-drift-detection/saas/src/index.ts index c9635b9..b1b3cbd 100644 --- a/products/02-iac-drift-detection/saas/src/index.ts +++ b/products/02-iac-drift-detection/saas/src/index.ts @@ -2,11 +2,11 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import { config } from './config/index.js'; -import { registerProcessorRoutes } from './processor/routes.js'; -import { registerApiRoutes } from './api/routes.js'; -import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; import { createPool } from './data/db.js'; import { createRedis } from './data/redis.js'; +import { registerProcessorRoutes } from './processor/routes.js'; +import { registerApiRoutes } from './api/routes.js'; +import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js'; const app = Fastify({ logger: { @@ -27,19 +27,22 @@ async function start() { app.decorate('redis', redis); app.decorate('config', config); - // Auth - registerAuth(app, config.jwtSecret, pool); + decorateAuth(app); - // Health (before auth) + // Public routes (no auth) app.get('/health', async () => ({ status: 'ok' })); -app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); + app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); - // Auth routes (signup/login) + // Auth routes (public - login/signup) registerAuthRoutes(app, config.jwtSecret, pool); - // Routes - await registerProcessorRoutes(app); - await registerApiRoutes(app); + // Protected routes (auth required) + app.register(async function protectedRoutes(protectedApp) { + protectedApp.addHook('onRequest', authHook(config.jwtSecret, pool)); + registerProtectedAuthRoutes(protectedApp, config.jwtSecret, pool); + await registerProcessorRoutes(protectedApp); + await registerApiRoutes(protectedApp); + }); await app.listen({ port: config.port, host: '0.0.0.0' }); app.log.info(`dd0c/drift SaaS listening on :${config.port}`); @@ -51,6 +54,3 @@ start().catch((err) => { }); export { app }; -// CI: 2026-03-01T06:52:14Z -// CI fix: 06:56 -// build: 2026-03-01T22:59:34Z diff --git a/products/03-alert-intelligence/migrations/003_invites.sql b/products/03-alert-intelligence/migrations/003_invites.sql new file mode 100644 index 0000000..c4630b0 --- /dev/null +++ b/products/03-alert-intelligence/migrations/003_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tenant_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')), + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email); diff --git a/products/03-alert-intelligence/package.json b/products/03-alert-intelligence/package.json index 97f45b3..e78f20c 100644 --- a/products/03-alert-intelligence/package.json +++ b/products/03-alert-intelligence/package.json @@ -13,6 +13,7 @@ "dependencies": { "fastify": "^4.28.0", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/helmet": "^11.1.0", "pg": "^8.12.0", "ioredis": "^5.4.0", diff --git a/products/03-alert-intelligence/src/api/webhooks.ts b/products/03-alert-intelligence/src/api/webhooks.ts index 234d0c5..900aba3 100644 --- a/products/03-alert-intelligence/src/api/webhooks.ts +++ b/products/03-alert-intelligence/src/api/webhooks.ts @@ -4,15 +4,13 @@ import { validateDatadogHmac, validatePagerdutyHmac, validateOpsgenieHmac, - normalizeDatadog, - normalizePagerduty, - normalizeOpsgenie, - type CanonicalAlert, } from '../ingestion/webhook.js'; -import { withTenant } from '../data/db.js'; +import { redis } from '../data/redis.js'; const logger = pino({ name: 'api-webhooks' }); +const REDIS_QUEUE = 'dd0c:webhooks:incoming'; + export function registerWebhookRoutes(app: FastifyInstance) { // Datadog webhook app.post('/webhooks/datadog/:tenantSlug', async (req, reply) => { @@ -34,8 +32,12 @@ export function registerWebhookRoutes(app: FastifyInstance) { return reply.status(401).send({ error: hmac.error }); } - const alert = normalizeDatadog(body); - await ingestAlert(secret.tenantId, alert); + await redis.lpush(REDIS_QUEUE, JSON.stringify({ + provider: 'datadog', + tenantId: secret.tenantId, + payload: body, + receivedAt: Date.now(), + })); return reply.status(202).send({ status: 'accepted' }); }); @@ -58,8 +60,12 @@ export function registerWebhookRoutes(app: FastifyInstance) { return reply.status(401).send({ error: hmac.error }); } - const alert = normalizePagerduty(body); - await ingestAlert(secret.tenantId, alert); + await redis.lpush(REDIS_QUEUE, JSON.stringify({ + provider: 'pagerduty', + tenantId: secret.tenantId, + payload: body, + receivedAt: Date.now(), + })); return reply.status(202).send({ status: 'accepted' }); }); @@ -82,8 +88,12 @@ export function registerWebhookRoutes(app: FastifyInstance) { return reply.status(401).send({ error: hmac.error }); } - const alert = normalizeOpsgenie(body); - await ingestAlert(secret.tenantId, alert); + await redis.lpush(REDIS_QUEUE, JSON.stringify({ + provider: 'opsgenie', + tenantId: secret.tenantId, + payload: body, + receivedAt: Date.now(), + })); return reply.status(202).send({ status: 'accepted' }); }); @@ -100,41 +110,19 @@ export function registerWebhookRoutes(app: FastifyInstance) { return reply.status(401).send({ error: 'Invalid token' }); } - const alert = normalizeGrafana(body); - await ingestAlert(secret.tenantId, alert); + await redis.lpush(REDIS_QUEUE, JSON.stringify({ + provider: 'grafana', + tenantId: secret.tenantId, + payload: body, + receivedAt: Date.now(), + })); return reply.status(202).send({ status: 'accepted' }); }); } -function normalizeGrafana(payload: any): CanonicalAlert { - const alert = payload.alerts?.[0] ?? payload; - return { - sourceProvider: 'grafana' as any, - sourceId: alert.fingerprint ?? crypto.randomUUID(), - fingerprint: alert.fingerprint ?? '', - title: alert.labels?.alertname ?? payload.title ?? 'Grafana Alert', - severity: mapGrafanaSeverity(alert.labels?.severity), - status: alert.status === 'resolved' ? 'resolved' : 'firing', - service: alert.labels?.service, - environment: alert.labels?.env, - tags: alert.labels ?? {}, - rawPayload: payload, - timestamp: alert.startsAt ? new Date(alert.startsAt).getTime() : Date.now(), - }; -} - -function mapGrafanaSeverity(s: string | undefined): CanonicalAlert['severity'] { - switch (s) { - case 'critical': return 'critical'; - case 'warning': return 'high'; - case 'info': return 'info'; - default: return 'medium'; - } -} - async function getWebhookSecret(tenantSlug: string, provider: string): Promise<{ tenantId: string; secret: string } | null> { - const { pool } = await import('../data/db.js'); - const result = await pool.query( + const { systemQuery } = await import('../data/db.js'); + const result = await systemQuery( `SELECT ws.secret, t.id as tenant_id FROM webhook_secrets ws JOIN tenants t ON ws.tenant_id = t.id @@ -144,51 +132,3 @@ async function getWebhookSecret(tenantSlug: string, provider: string): Promise<{ if (!result.rows[0]) return null; return { tenantId: result.rows[0].tenant_id, secret: result.rows[0].secret }; } - -async function ingestAlert(tenantId: string, alert: CanonicalAlert): Promise { - await withTenant(tenantId, async (client) => { - // Persist raw alert - const alertResult = await client.query( - `INSERT INTO alerts (tenant_id, source_provider, source_id, fingerprint, title, severity, status, service, environment, tags, raw_payload) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id`, - [tenantId, alert.sourceProvider, alert.sourceId, alert.fingerprint, alert.title, alert.severity, alert.status, alert.service, alert.environment, JSON.stringify(alert.tags), JSON.stringify(alert.rawPayload)], - ); - const alertId = alertResult.rows[0].id; - - // Check for existing open incident with same fingerprint - const existing = await client.query( - `SELECT id, alert_count FROM incidents - WHERE fingerprint = $1 AND status IN ('open', 'acknowledged') - ORDER BY created_at DESC LIMIT 1`, - [alert.fingerprint], - ); - - if (existing.rows[0]) { - // Attach to existing incident - await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [existing.rows[0].id, alertId]); - await client.query( - `UPDATE incidents SET alert_count = alert_count + 1, last_alert_at = now() WHERE id = $1`, - [existing.rows[0].id], - ); - } else if (alert.status === 'firing') { - // Create new incident - const incident = await client.query( - `INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at) - VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now()) - RETURNING id`, - [tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity], - ); - await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [incident.rows[0].id, alertId]); - } - - // Auto-resolve if alert status is resolved - if (alert.status === 'resolved') { - await client.query( - `UPDATE incidents SET status = 'resolved', resolved_at = now() - WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')`, - [alert.fingerprint], - ); - } - }); -} diff --git a/products/03-alert-intelligence/src/auth/middleware.ts b/products/03-alert-intelligence/src/auth/middleware.ts index 321df57..fb58a68 100644 --- a/products/03-alert-intelligence/src/auth/middleware.ts +++ b/products/03-alert-intelligence/src/auth/middleware.ts @@ -15,21 +15,11 @@ export interface AuthPayload { } /** - * JWT auth middleware. Extracts tenant context from Bearer token. - * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + * Returns an onRequest hook that validates JWT or API key auth. + * No URL matching — only register this hook inside a protected plugin scope. */ -export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.decorateRequest('tenantId', ''); - app.decorateRequest('userId', ''); - app.decorateRequest('userRole', 'viewer'); - - app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { - if (req.url === '/health' || req.url === '/version') return; - if (req.url.startsWith('/webhooks/')) return; - if (req.url.startsWith('/slack/')) return; - const path = req.url.split('?')[0]; - if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return; - +export function authHook(jwtSecret: string, pool: Pool) { + return async (req: FastifyRequest, reply: FastifyReply) => { const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; @@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool return reply.status(401).send({ error: 'Invalid API key format' }); } - const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars + const prefix = apiKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( @@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, jwtSecret) as AuthPayload; + const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload; (req as any).tenantId = payload.tenantId; (req as any).userId = payload.userId; (req as any).userRole = payload.role; @@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool } return reply.status(401).send({ error: 'Missing authentication' }); - }); + }; +} + +/** + * Decorate the Fastify request with auth properties. + * Call this once on the root app instance before registering any routes. + */ +export function decorateAuth(app: FastifyInstance) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); } export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { @@ -123,11 +123,21 @@ const loginSchema = z.object({ const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), - tenant_name: z.string().min(1).max(100), + tenant_name: z.string().min(1).max(100).optional(), + invite_token: z.string().optional(), +}).refine( + (data) => data.invite_token || data.tenant_name, + { message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] }, +); + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member', 'viewer']).default('member'), }); +/** Public auth routes — login/signup. No auth required. */ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.post('/api/v1/auth/login', async (req, reply) => { + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( @@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool return { token, expires_in: '24h' }; }); - app.post('/api/v1/auth/signup', async (req, reply) => { + app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => { const body = signupSchema.parse(req.body); - // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); - const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); - const tenant = await client.query( - `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, - [body.tenant_name, slug], - ); - const tenantId = tenant.rows[0].id; + let tenantId: string; + let role: string; + + if (body.invite_token) { + const invite = await client.query( + `SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, + [body.invite_token], + ); + if (!invite.rows[0]) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invalid invite token' }); + } + const inv = invite.rows[0]; + if (inv.accepted_at) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite already accepted' }); + } + if (new Date(inv.expires_at) < new Date()) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite expired' }); + } + + tenantId = inv.tenant_id; + role = inv.role; + + await client.query( + `UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`, + [inv.id], + ); + } else { + if (!body.tenant_name) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'tenant_name is required for new signups' }); + } + const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); + const tenant = await client.query( + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + [body.tenant_name, slug], + ); + tenantId = tenant.rows[0].id; + role = 'owner'; + } const user = await client.query( - `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, - [tenantId, body.email, passwordHash], + `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`, + [tenantId, body.email, passwordHash, role], ); await client.query('COMMIT'); @@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool tenantId, userId: user.rows[0].id, email: body.email, - role: 'owner', + role: role as AuthPayload['role'], }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); @@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool client.release(); } }); +} +/** Protected auth routes — me, api-keys. Must be registered inside an auth-protected plugin scope. */ +export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { app.get('/api/v1/auth/me', async (req, reply) => { return { tenant_id: (req as any).tenantId, @@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool }; }); - // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; @@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool [tenantId, userId, prefix, keyHash], ); - // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); + + // --- Invite endpoints --- + + app.post('/api/v1/auth/invite', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + const body = inviteSchema.parse(req.body); + + const token = crypto.randomBytes(32).toString('hex'); + const result = await pool.query( + `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING expires_at`, + [tenantId, body.email, body.role, token, userId], + ); + + return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); + }); + + app.get('/api/v1/auth/invites', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + + const result = await pool.query( + `SELECT id, email, role, expires_at, created_at FROM tenant_invites + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [tenantId], + ); + + return { invites: result.rows }; + }); + + app.delete('/api/v1/auth/invites/:id', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const { id } = req.params as { id: string }; + + const result = await pool.query( + `DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`, + [id, tenantId], + ); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' }); + return { deleted: true }; + }); } diff --git a/products/03-alert-intelligence/src/config/index.ts b/products/03-alert-intelligence/src/config/index.ts index 894873e..261d769 100644 --- a/products/03-alert-intelligence/src/config/index.ts +++ b/products/03-alert-intelligence/src/config/index.ts @@ -5,7 +5,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_alert'), REDIS_URL: z.string().default('redis://localhost:6379'), JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'), - CORS_ORIGIN: z.string().default('*'), + CORS_ORIGIN: z.string().default('http://localhost:5173'), LOG_LEVEL: z.string().default('info'), }); diff --git a/products/03-alert-intelligence/src/data/db.ts b/products/03-alert-intelligence/src/data/db.ts index 005c292..f95466d 100644 --- a/products/03-alert-intelligence/src/data/db.ts +++ b/products/03-alert-intelligence/src/data/db.ts @@ -4,12 +4,11 @@ import { config } from '../config/index.js'; const logger = pino({ name: 'data' }); -export const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); /** * RLS tenant isolation wrapper. * Sets `app.tenant_id` for the duration of the callback, then resets. - * Prevents connection pool tenant context leakage (BMad must-have). */ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient) => Promise): Promise { const client = await pool.connect(); @@ -27,3 +26,15 @@ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient client.release(); } } + +/** System-level queries that intentionally bypass RLS (auth, migrations, health) */ +export async function systemQuery( + text: string, params?: any[] +): Promise> { + return pool.query(text, params); +} + +/** For auth middleware that needs direct pool access for API key lookups */ +export function getPoolForAuth(): pg.Pool { + return pool; +} diff --git a/products/03-alert-intelligence/src/data/redis.ts b/products/03-alert-intelligence/src/data/redis.ts new file mode 100644 index 0000000..9af0cb7 --- /dev/null +++ b/products/03-alert-intelligence/src/data/redis.ts @@ -0,0 +1,9 @@ +import Redis from 'ioredis'; +import { config } from '../config/index.js'; + +export const redis = new Redis(config.REDIS_URL, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + return Math.min(times * 200, 3000); + }, +}); diff --git a/products/03-alert-intelligence/src/index.ts b/products/03-alert-intelligence/src/index.ts index c76b470..82a8348 100644 --- a/products/03-alert-intelligence/src/index.ts +++ b/products/03-alert-intelligence/src/index.ts @@ -3,12 +3,13 @@ import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import pino from 'pino'; import { config } from './config/index.js'; -import { pool } from './data/db.js'; -import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; +import { getPoolForAuth } from './data/db.js'; +import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js'; import { registerWebhookRoutes } from './api/webhooks.js'; import { registerWebhookSecretRoutes } from './api/webhook_secrets.js'; import { registerIncidentRoutes } from './api/incidents.js'; import { registerNotificationRoutes } from './api/notifications.js'; +import { startWebhookProcessor } from './workers/webhook-processor.js'; const logger = pino({ name: 'dd0c-alert', level: config.LOG_LEVEL }); @@ -17,24 +18,31 @@ const app = Fastify({ logger: true }); await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(helmet); -registerAuth(app, config.JWT_SECRET, pool); +const pool = getPoolForAuth(); +decorateAuth(app); -app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' } /* v:c4ec43c */)); +// Public routes (no auth) +app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' })); app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); - -registerAuthRoutes(app, config.JWT_SECRET, pool); registerWebhookRoutes(app); -registerWebhookSecretRoutes(app); -registerIncidentRoutes(app); -registerNotificationRoutes(app); + +// Auth routes (public - login/signup) +registerAuthRoutes(app, config.JWT_SECRET, pool); + +// Protected routes (auth required) +app.register(async function protectedRoutes(protectedApp) { + protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool)); + registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool); + registerIncidentRoutes(protectedApp); + registerNotificationRoutes(protectedApp); + registerWebhookSecretRoutes(protectedApp); +}); try { await app.listen({ port: config.PORT, host: '0.0.0.0' }); logger.info({ port: config.PORT }, 'dd0c/alert started'); + startWebhookProcessor().catch((err) => logger.error(err, 'Webhook processor crashed')); } catch (err) { logger.fatal(err, 'Failed to start'); process.exit(1); } -// Build: 2026-03-01T06:43:58Z -// Build: Sun Mar 1 06:47:59 UTC 2026 -// CI fix: 06:56 diff --git a/products/03-alert-intelligence/src/workers/webhook-processor.ts b/products/03-alert-intelligence/src/workers/webhook-processor.ts new file mode 100644 index 0000000..786d1df --- /dev/null +++ b/products/03-alert-intelligence/src/workers/webhook-processor.ts @@ -0,0 +1,149 @@ +import pino from 'pino'; +import { redis } from '../data/redis.js'; +import { withTenant } from '../data/db.js'; +import { + normalizeDatadog, + normalizePagerduty, + normalizeOpsgenie, + type CanonicalAlert, +} from '../ingestion/webhook.js'; + +const logger = pino({ name: 'webhook-processor' }); + +const INCOMING_QUEUE = 'dd0c:webhooks:incoming'; +const DEAD_LETTER_QUEUE = 'dd0c:webhooks:dead-letter'; +const MAX_RETRIES = 3; + +interface QueuedWebhook { + provider: string; + tenantId: string; + payload: any; + receivedAt: number; + retries?: number; +} + +function normalizeGrafana(payload: any): CanonicalAlert { + const alert = payload.alerts?.[0] ?? payload; + return { + sourceProvider: 'grafana' as any, + sourceId: alert.fingerprint ?? crypto.randomUUID(), + fingerprint: alert.fingerprint ?? '', + title: alert.labels?.alertname ?? payload.title ?? 'Grafana Alert', + severity: mapGrafanaSeverity(alert.labels?.severity), + status: alert.status === 'resolved' ? 'resolved' : 'firing', + service: alert.labels?.service, + environment: alert.labels?.env, + tags: alert.labels ?? {}, + rawPayload: payload, + timestamp: alert.startsAt ? new Date(alert.startsAt).getTime() : Date.now(), + }; +} + +function mapGrafanaSeverity(s: string | undefined): CanonicalAlert['severity'] { + switch (s) { + case 'critical': return 'critical'; + case 'warning': return 'high'; + case 'info': return 'info'; + default: return 'medium'; + } +} + +function normalizeByProvider(provider: string, payload: any): CanonicalAlert { + switch (provider) { + case 'datadog': return normalizeDatadog(payload); + case 'pagerduty': return normalizePagerduty(payload); + case 'opsgenie': return normalizeOpsgenie(payload); + case 'grafana': return normalizeGrafana(payload); + default: throw new Error(`Unknown provider: ${provider}`); + } +} + +async function processWebhook(item: QueuedWebhook): Promise { + const alert = normalizeByProvider(item.provider, item.payload); + + await withTenant(item.tenantId, async (client) => { + const alertResult = await client.query( + `INSERT INTO alerts (tenant_id, source_provider, source_id, fingerprint, title, severity, status, service, environment, tags, raw_payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id`, + [item.tenantId, alert.sourceProvider, alert.sourceId, alert.fingerprint, alert.title, alert.severity, alert.status, alert.service, alert.environment, JSON.stringify(alert.tags), JSON.stringify(alert.rawPayload)], + ); + const alertId = alertResult.rows[0].id; + + const existing = await client.query( + `SELECT id, alert_count FROM incidents + WHERE fingerprint = $1 AND status IN ('open', 'acknowledged') + ORDER BY created_at DESC LIMIT 1`, + [alert.fingerprint], + ); + + if (existing.rows[0]) { + await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [existing.rows[0].id, alertId]); + await client.query( + `UPDATE incidents SET alert_count = alert_count + 1, last_alert_at = now() WHERE id = $1`, + [existing.rows[0].id], + ); + } else if (alert.status === 'firing') { + const incident = await client.query( + `INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at) + VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now()) + RETURNING id`, + [item.tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity], + ); + await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [incident.rows[0].id, alertId]); + } + + if (alert.status === 'resolved') { + await client.query( + `UPDATE incidents SET status = 'resolved', resolved_at = now() + WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')`, + [alert.fingerprint], + ); + } + }); +} + +let running = false; + +export async function startWebhookProcessor(): Promise { + running = true; + logger.info('Webhook processor started'); + + while (running) { + try { + const result = await redis.brpop(INCOMING_QUEUE, 5); + if (!result) continue; + + const [, raw] = result; + let item: QueuedWebhook; + try { + item = JSON.parse(raw); + } catch { + logger.error({ raw }, 'Failed to parse queued webhook'); + await redis.lpush(DEAD_LETTER_QUEUE, raw); + continue; + } + + try { + await processWebhook(item); + logger.debug({ provider: item.provider, tenantId: item.tenantId }, 'Webhook processed'); + } catch (err) { + const retries = (item.retries ?? 0) + 1; + if (retries >= MAX_RETRIES) { + logger.error({ err, item }, 'Webhook processing failed, moving to dead-letter queue'); + await redis.lpush(DEAD_LETTER_QUEUE, JSON.stringify({ ...item, retries, error: String(err) })); + } else { + logger.warn({ err, retries }, 'Webhook processing failed, retrying'); + await redis.lpush(INCOMING_QUEUE, JSON.stringify({ ...item, retries })); + } + } + } catch (err) { + logger.error({ err }, 'Webhook processor loop error'); + await new Promise((r) => setTimeout(r, 1000)); + } + } +} + +export function stopWebhookProcessor(): void { + running = false; +} diff --git a/products/04-lightweight-idp/migrations/003_invites.sql b/products/04-lightweight-idp/migrations/003_invites.sql new file mode 100644 index 0000000..c4630b0 --- /dev/null +++ b/products/04-lightweight-idp/migrations/003_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tenant_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')), + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email); diff --git a/products/04-lightweight-idp/package.json b/products/04-lightweight-idp/package.json index e13be6a..8559ba1 100644 --- a/products/04-lightweight-idp/package.json +++ b/products/04-lightweight-idp/package.json @@ -13,6 +13,7 @@ "dependencies": { "fastify": "^4.28.0", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/helmet": "^11.1.0", "pg": "^8.12.0", "ioredis": "^5.4.0", diff --git a/products/04-lightweight-idp/src/api/discovery.ts b/products/04-lightweight-idp/src/api/discovery.ts index 307086c..3ab7a0f 100644 --- a/products/04-lightweight-idp/src/api/discovery.ts +++ b/products/04-lightweight-idp/src/api/discovery.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import pino from 'pino'; import { Redis } from 'ioredis'; -import { withTenant, pool } from '../data/db.js'; +import { withTenant, getPoolForAuth } from '../data/db.js'; import { config } from '../config/index.js'; import { AwsDiscoveryScanner } from '../discovery/aws-scanner.js'; import { GitHubDiscoveryScanner } from '../discovery/github-scanner.js'; @@ -10,6 +10,7 @@ import { ScheduledDiscovery } from '../discovery/scheduler.js'; const logger = pino({ name: 'api-discovery' }); const redis = new Redis(config.REDIS_URL); +const pool = getPoolForAuth(); const scheduler = new ScheduledDiscovery(redis, pool); const catalog = new CatalogService(pool); diff --git a/products/04-lightweight-idp/src/auth/middleware.ts b/products/04-lightweight-idp/src/auth/middleware.ts index 321df57..3eed6ec 100644 --- a/products/04-lightweight-idp/src/auth/middleware.ts +++ b/products/04-lightweight-idp/src/auth/middleware.ts @@ -15,21 +15,11 @@ export interface AuthPayload { } /** - * JWT auth middleware. Extracts tenant context from Bearer token. - * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + * Returns an onRequest hook that validates JWT or API key auth. + * No URL matching — only register this hook inside a protected plugin scope. */ -export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.decorateRequest('tenantId', ''); - app.decorateRequest('userId', ''); - app.decorateRequest('userRole', 'viewer'); - - app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { - if (req.url === '/health' || req.url === '/version') return; - if (req.url.startsWith('/webhooks/')) return; - if (req.url.startsWith('/slack/')) return; - const path = req.url.split('?')[0]; - if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return; - +export function authHook(jwtSecret: string, pool: Pool) { + return async (req: FastifyRequest, reply: FastifyReply) => { const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; @@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool return reply.status(401).send({ error: 'Invalid API key format' }); } - const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars + const prefix = apiKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( @@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, jwtSecret) as AuthPayload; + const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload; (req as any).tenantId = payload.tenantId; (req as any).userId = payload.userId; (req as any).userRole = payload.role; @@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool } return reply.status(401).send({ error: 'Missing authentication' }); - }); + }; +} + +/** + * Decorate the Fastify request with auth properties. + * Call this once on the root app instance before registering any routes. + */ +export function decorateAuth(app: FastifyInstance) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); } export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { @@ -123,11 +123,21 @@ const loginSchema = z.object({ const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), - tenant_name: z.string().min(1).max(100), + tenant_name: z.string().min(1).max(100).optional(), + invite_token: z.string().optional(), +}).refine( + (data) => data.invite_token || data.tenant_name, + { message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] }, +); + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member', 'viewer']).default('member'), }); +/** Public auth routes — login/signup. No auth required. */ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.post('/api/v1/auth/login', async (req, reply) => { + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( @@ -152,29 +162,65 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool return { token, expires_in: '24h' }; }); - app.post('/api/v1/auth/signup', async (req, reply) => { + app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => { const body = signupSchema.parse(req.body); - // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); - const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); - const tenant = await client.query( - `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, - [body.tenant_name, slug], - ); - const tenantId = tenant.rows[0].id; + let tenantId: string; + let role: string; + + if (body.invite_token) { + const invite = await client.query( + `SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, + [body.invite_token], + ); + if (!invite.rows[0]) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invalid invite token' }); + } + const inv = invite.rows[0]; + if (inv.accepted_at) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite already accepted' }); + } + if (new Date(inv.expires_at) < new Date()) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite expired' }); + } + + tenantId = inv.tenant_id; + role = inv.role; + + await client.query( + `UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`, + [inv.id], + ); + } else { + const tenantName = body.tenant_name; + if (!tenantName) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'tenant_name is required for new signups' }); + } + const slug = tenantName.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); + const tenant = await client.query( + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + [tenantName, slug], + ); + tenantId = tenant.rows[0].id; + role = 'owner'; + } const user = await client.query( - `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, - [tenantId, body.email, passwordHash], + `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`, + [tenantId, body.email, passwordHash, role], ); await client.query('COMMIT'); @@ -183,7 +229,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool tenantId, userId: user.rows[0].id, email: body.email, - role: 'owner', + role: role as AuthPayload['role'], }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); @@ -194,7 +240,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool client.release(); } }); +} +/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */ +export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { app.get('/api/v1/auth/me', async (req, reply) => { return { tenant_id: (req as any).tenantId, @@ -203,7 +252,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool }; }); - // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; @@ -219,7 +267,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool [tenantId, userId, prefix, keyHash], ); - // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); + + // --- Invite endpoints --- + + app.post('/api/v1/auth/invite', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + const body = inviteSchema.parse(req.body); + + const token = crypto.randomBytes(32).toString('hex'); + const result = await pool.query( + `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING expires_at`, + [tenantId, body.email, body.role, token, userId], + ); + + return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); + }); + + app.get('/api/v1/auth/invites', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + + const result = await pool.query( + `SELECT id, email, role, expires_at, created_at FROM tenant_invites + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [tenantId], + ); + + return { invites: result.rows }; + }); + + app.delete('/api/v1/auth/invites/:id', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const { id } = req.params as { id: string }; + + const result = await pool.query( + `DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`, + [id, tenantId], + ); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' }); + return { deleted: true }; + }); } diff --git a/products/04-lightweight-idp/src/config/index.ts b/products/04-lightweight-idp/src/config/index.ts index 9ef5ed6..a81b677 100644 --- a/products/04-lightweight-idp/src/config/index.ts +++ b/products/04-lightweight-idp/src/config/index.ts @@ -7,7 +7,7 @@ const envSchema = z.object({ 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('*'), + CORS_ORIGIN: z.string().default('http://localhost:5173'), LOG_LEVEL: z.string().default('info'), }); diff --git a/products/04-lightweight-idp/src/data/db.ts b/products/04-lightweight-idp/src/data/db.ts index 85cd7f5..f95466d 100644 --- a/products/04-lightweight-idp/src/data/db.ts +++ b/products/04-lightweight-idp/src/data/db.ts @@ -4,8 +4,12 @@ import { config } from '../config/index.js'; const logger = pino({ name: 'data' }); -export const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +/** + * RLS tenant isolation wrapper. + * Sets `app.tenant_id` for the duration of the callback, then resets. + */ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient) => Promise): Promise { const client = await pool.connect(); try { @@ -22,3 +26,15 @@ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient client.release(); } } + +/** System-level queries that intentionally bypass RLS (auth, migrations, health) */ +export async function systemQuery( + text: string, params?: any[] +): Promise> { + return pool.query(text, params); +} + +/** For auth middleware that needs direct pool access for API key lookups */ +export function getPoolForAuth(): pg.Pool { + return pool; +} diff --git a/products/04-lightweight-idp/src/index.ts b/products/04-lightweight-idp/src/index.ts index f0b82e7..f6ae9c6 100644 --- a/products/04-lightweight-idp/src/index.ts +++ b/products/04-lightweight-idp/src/index.ts @@ -3,8 +3,8 @@ import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import pino from 'pino'; import { config } from './config/index.js'; -import { pool } from './data/db.js'; -import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; +import { getPoolForAuth } from './data/db.js'; +import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js'; import { registerServiceRoutes } from './api/services.js'; import { registerDiscoveryRoutes } from './api/discovery.js'; import { registerSearchRoutes } from './api/search.js'; @@ -16,15 +16,24 @@ const app = Fastify({ logger: true }); await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(helmet); -registerAuth(app, config.JWT_SECRET, pool); +const pool = getPoolForAuth(); +decorateAuth(app); -app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' } /* v:c4ec43c */)); +// Public routes (no auth) +app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' })); app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); +// Auth routes (public - login/signup) registerAuthRoutes(app, config.JWT_SECRET, pool); -registerServiceRoutes(app); -registerDiscoveryRoutes(app); -registerSearchRoutes(app); + +// Protected routes (auth required) +app.register(async function protectedRoutes(protectedApp) { + protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool)); + registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool); + registerServiceRoutes(protectedApp); + registerDiscoveryRoutes(protectedApp); + registerSearchRoutes(protectedApp); +}); try { await app.listen({ port: config.PORT, host: '0.0.0.0' }); @@ -33,6 +42,3 @@ try { logger.fatal(err, 'Failed to start'); process.exit(1); } -// Build: 2026-03-01T06:43:58Z -// CI: 2026-03-01T06:52:14Z -// CI fix: 06:56 diff --git a/products/05-aws-cost-anomaly/migrations/003_invites.sql b/products/05-aws-cost-anomaly/migrations/003_invites.sql new file mode 100644 index 0000000..c4630b0 --- /dev/null +++ b/products/05-aws-cost-anomaly/migrations/003_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tenant_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')), + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email); diff --git a/products/05-aws-cost-anomaly/package.json b/products/05-aws-cost-anomaly/package.json index 419489a..6eda056 100644 --- a/products/05-aws-cost-anomaly/package.json +++ b/products/05-aws-cost-anomaly/package.json @@ -13,6 +13,7 @@ "dependencies": { "fastify": "^4.28.0", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "pg": "^8.12.0", "ioredis": "^5.4.0", "zod": "^3.23.0", diff --git a/products/05-aws-cost-anomaly/src/auth/middleware.ts b/products/05-aws-cost-anomaly/src/auth/middleware.ts index 321df57..78ecd11 100644 --- a/products/05-aws-cost-anomaly/src/auth/middleware.ts +++ b/products/05-aws-cost-anomaly/src/auth/middleware.ts @@ -15,21 +15,11 @@ export interface AuthPayload { } /** - * JWT auth middleware. Extracts tenant context from Bearer token. - * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + * Returns an onRequest hook that validates JWT or API key auth. + * No URL matching — only register this hook inside a protected plugin scope. */ -export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.decorateRequest('tenantId', ''); - app.decorateRequest('userId', ''); - app.decorateRequest('userRole', 'viewer'); - - app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { - if (req.url === '/health' || req.url === '/version') return; - if (req.url.startsWith('/webhooks/')) return; - if (req.url.startsWith('/slack/')) return; - const path = req.url.split('?')[0]; - if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return; - +export function authHook(jwtSecret: string, pool: Pool) { + return async (req: FastifyRequest, reply: FastifyReply) => { const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; @@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool return reply.status(401).send({ error: 'Invalid API key format' }); } - const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars + const prefix = apiKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( @@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, jwtSecret) as AuthPayload; + const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload; (req as any).tenantId = payload.tenantId; (req as any).userId = payload.userId; (req as any).userRole = payload.role; @@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool } return reply.status(401).send({ error: 'Missing authentication' }); - }); + }; +} + +/** + * Decorate the Fastify request with auth properties. + * Call this once on the root app instance before registering any routes. + */ +export function decorateAuth(app: FastifyInstance) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); } export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { @@ -123,11 +123,21 @@ const loginSchema = z.object({ const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), - tenant_name: z.string().min(1).max(100), + tenant_name: z.string().min(1).max(100).optional(), + invite_token: z.string().optional(), +}).refine( + (data) => data.invite_token || data.tenant_name, + { message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] }, +); + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member', 'viewer']).default('member'), }); +/** Public auth routes — login/signup. No auth required. */ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.post('/api/v1/auth/login', async (req, reply) => { + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( @@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool return { token, expires_in: '24h' }; }); - app.post('/api/v1/auth/signup', async (req, reply) => { + app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => { const body = signupSchema.parse(req.body); - // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); - const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); - const tenant = await client.query( - `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, - [body.tenant_name, slug], - ); - const tenantId = tenant.rows[0].id; + let tenantId: string; + let role: string; + + if (body.invite_token) { + const invite = await client.query( + `SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, + [body.invite_token], + ); + if (!invite.rows[0]) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invalid invite token' }); + } + const inv = invite.rows[0]; + if (inv.accepted_at) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite already accepted' }); + } + if (new Date(inv.expires_at) < new Date()) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite expired' }); + } + + tenantId = inv.tenant_id; + role = inv.role; + + await client.query( + `UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`, + [inv.id], + ); + } else { + if (!body.tenant_name) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'tenant_name is required for new signups' }); + } + const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); + const tenant = await client.query( + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + [body.tenant_name, slug], + ); + tenantId = tenant.rows[0].id; + role = 'owner'; + } const user = await client.query( - `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, - [tenantId, body.email, passwordHash], + `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`, + [tenantId, body.email, passwordHash, role], ); await client.query('COMMIT'); @@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool tenantId, userId: user.rows[0].id, email: body.email, - role: 'owner', + role: role as AuthPayload['role'], }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); @@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool client.release(); } }); +} +/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */ +export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { app.get('/api/v1/auth/me', async (req, reply) => { return { tenant_id: (req as any).tenantId, @@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool }; }); - // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; @@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool [tenantId, userId, prefix, keyHash], ); - // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); + + // --- Invite endpoints --- + + app.post('/api/v1/auth/invite', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + const body = inviteSchema.parse(req.body); + + const token = crypto.randomBytes(32).toString('hex'); + const result = await pool.query( + `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING expires_at`, + [tenantId, body.email, body.role, token, userId], + ); + + return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); + }); + + app.get('/api/v1/auth/invites', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + + const result = await pool.query( + `SELECT id, email, role, expires_at, created_at FROM tenant_invites + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [tenantId], + ); + + return { invites: result.rows }; + }); + + app.delete('/api/v1/auth/invites/:id', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const { id } = req.params as { id: string }; + + const result = await pool.query( + `DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`, + [id, tenantId], + ); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' }); + return { deleted: true }; + }); } diff --git a/products/05-aws-cost-anomaly/src/config/index.ts b/products/05-aws-cost-anomaly/src/config/index.ts index 3dbc8ef..6d27af3 100644 --- a/products/05-aws-cost-anomaly/src/config/index.ts +++ b/products/05-aws-cost-anomaly/src/config/index.ts @@ -5,7 +5,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_cost'), REDIS_URL: z.string().default('redis://localhost:6379'), JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'), - CORS_ORIGIN: z.string().default('*'), + CORS_ORIGIN: z.string().default('http://localhost:5173'), LOG_LEVEL: z.string().default('info'), ANOMALY_THRESHOLD: z.coerce.number().default(50), }); diff --git a/products/05-aws-cost-anomaly/src/data/db.ts b/products/05-aws-cost-anomaly/src/data/db.ts index 85cd7f5..f95466d 100644 --- a/products/05-aws-cost-anomaly/src/data/db.ts +++ b/products/05-aws-cost-anomaly/src/data/db.ts @@ -4,8 +4,12 @@ import { config } from '../config/index.js'; const logger = pino({ name: 'data' }); -export const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +/** + * RLS tenant isolation wrapper. + * Sets `app.tenant_id` for the duration of the callback, then resets. + */ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient) => Promise): Promise { const client = await pool.connect(); try { @@ -22,3 +26,15 @@ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient client.release(); } } + +/** System-level queries that intentionally bypass RLS (auth, migrations, health) */ +export async function systemQuery( + text: string, params?: any[] +): Promise> { + return pool.query(text, params); +} + +/** For auth middleware that needs direct pool access for API key lookups */ +export function getPoolForAuth(): pg.Pool { + return pool; +} diff --git a/products/05-aws-cost-anomaly/src/index.ts b/products/05-aws-cost-anomaly/src/index.ts index 0702db2..6db0816 100644 --- a/products/05-aws-cost-anomaly/src/index.ts +++ b/products/05-aws-cost-anomaly/src/index.ts @@ -2,8 +2,8 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import pino from 'pino'; import { config } from './config/index.js'; -import { pool } from './data/db.js'; -import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; +import { getPoolForAuth } from './data/db.js'; +import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js'; import { registerAnomalyRoutes } from './api/anomalies.js'; import { registerBaselineRoutes } from './api/baselines.js'; import { registerGovernanceRoutes } from './api/governance.js'; @@ -15,16 +15,25 @@ const app = Fastify({ logger: true }); await app.register(cors, { origin: config.CORS_ORIGIN }); -registerAuth(app, config.JWT_SECRET, pool); +const pool = getPoolForAuth(); +decorateAuth(app); -app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' } /* v:c4ec43c */)); +// Public routes (no auth) +app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' })); app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); +// Auth routes (public - login/signup) registerAuthRoutes(app, config.JWT_SECRET, pool); -registerIngestionRoutes(app); -registerAnomalyRoutes(app); -registerBaselineRoutes(app); -registerGovernanceRoutes(app); + +// Protected routes (auth required) +app.register(async function protectedRoutes(protectedApp) { + protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool)); + registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool); + registerIngestionRoutes(protectedApp); + registerAnomalyRoutes(protectedApp); + registerBaselineRoutes(protectedApp); + registerGovernanceRoutes(protectedApp); +}); try { await app.listen({ port: config.PORT, host: '0.0.0.0' }); @@ -33,6 +42,3 @@ try { logger.fatal(err, 'Failed to start'); process.exit(1); } -// Build: 2026-03-01T06:43:58Z -// CI: 2026-03-01T06:52:14Z -// CI fix: 06:56 diff --git a/products/06-runbook-automation/saas/migrations/003_invites.sql b/products/06-runbook-automation/saas/migrations/003_invites.sql new file mode 100644 index 0000000..c4630b0 --- /dev/null +++ b/products/06-runbook-automation/saas/migrations/003_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS tenant_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')), + token TEXT NOT NULL UNIQUE, + invited_by UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token); +CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email); diff --git a/products/06-runbook-automation/saas/package.json b/products/06-runbook-automation/saas/package.json index bd3f3d9..991a293 100644 --- a/products/06-runbook-automation/saas/package.json +++ b/products/06-runbook-automation/saas/package.json @@ -13,6 +13,7 @@ "dependencies": { "fastify": "^4.28.0", "@fastify/cors": "^9.0.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/helmet": "^11.1.0", "@fastify/websocket": "^10.0.0", "pg": "^8.12.0", diff --git a/products/06-runbook-automation/saas/src/auth/middleware.ts b/products/06-runbook-automation/saas/src/auth/middleware.ts index 321df57..78ecd11 100644 --- a/products/06-runbook-automation/saas/src/auth/middleware.ts +++ b/products/06-runbook-automation/saas/src/auth/middleware.ts @@ -15,21 +15,11 @@ export interface AuthPayload { } /** - * JWT auth middleware. Extracts tenant context from Bearer token. - * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + * Returns an onRequest hook that validates JWT or API key auth. + * No URL matching — only register this hook inside a protected plugin scope. */ -export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.decorateRequest('tenantId', ''); - app.decorateRequest('userId', ''); - app.decorateRequest('userRole', 'viewer'); - - app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { - if (req.url === '/health' || req.url === '/version') return; - if (req.url.startsWith('/webhooks/')) return; - if (req.url.startsWith('/slack/')) return; - const path = req.url.split('?')[0]; - if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return; - +export function authHook(jwtSecret: string, pool: Pool) { + return async (req: FastifyRequest, reply: FastifyReply) => { const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; @@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool return reply.status(401).send({ error: 'Invalid API key format' }); } - const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars + const prefix = apiKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( @@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); try { - const payload = jwt.verify(token, jwtSecret) as AuthPayload; + const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload; (req as any).tenantId = payload.tenantId; (req as any).userId = payload.userId; (req as any).userRole = payload.role; @@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool } return reply.status(401).send({ error: 'Missing authentication' }); - }); + }; +} + +/** + * Decorate the Fastify request with auth properties. + * Call this once on the root app instance before registering any routes. + */ +export function decorateAuth(app: FastifyInstance) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); } export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { @@ -123,11 +123,21 @@ const loginSchema = z.object({ const signupSchema = z.object({ email: z.string().email(), password: z.string().min(8), - tenant_name: z.string().min(1).max(100), + tenant_name: z.string().min(1).max(100).optional(), + invite_token: z.string().optional(), +}).refine( + (data) => data.invite_token || data.tenant_name, + { message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] }, +); + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member', 'viewer']).default('member'), }); +/** Public auth routes — login/signup. No auth required. */ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { - app.post('/api/v1/auth/login', async (req, reply) => { + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( @@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool return { token, expires_in: '24h' }; }); - app.post('/api/v1/auth/signup', async (req, reply) => { + app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => { const body = signupSchema.parse(req.body); - // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); - const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); - const tenant = await client.query( - `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, - [body.tenant_name, slug], - ); - const tenantId = tenant.rows[0].id; + let tenantId: string; + let role: string; + + if (body.invite_token) { + const invite = await client.query( + `SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`, + [body.invite_token], + ); + if (!invite.rows[0]) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invalid invite token' }); + } + const inv = invite.rows[0]; + if (inv.accepted_at) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite already accepted' }); + } + if (new Date(inv.expires_at) < new Date()) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'Invite expired' }); + } + + tenantId = inv.tenant_id; + role = inv.role; + + await client.query( + `UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`, + [inv.id], + ); + } else { + if (!body.tenant_name) { + await client.query('ROLLBACK'); + return reply.status(400).send({ error: 'tenant_name is required for new signups' }); + } + const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); + const tenant = await client.query( + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + [body.tenant_name, slug], + ); + tenantId = tenant.rows[0].id; + role = 'owner'; + } const user = await client.query( - `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, - [tenantId, body.email, passwordHash], + `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`, + [tenantId, body.email, passwordHash, role], ); await client.query('COMMIT'); @@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool tenantId, userId: user.rows[0].id, email: body.email, - role: 'owner', + role: role as AuthPayload['role'], }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); @@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool client.release(); } }); +} +/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */ +export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { app.get('/api/v1/auth/me', async (req, reply) => { return { tenant_id: (req as any).tenantId, @@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool }; }); - // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; @@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool [tenantId, userId, prefix, keyHash], ); - // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); + + // --- Invite endpoints --- + + app.post('/api/v1/auth/invite', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const userId = (req as any).userId; + const body = inviteSchema.parse(req.body); + + const token = crypto.randomBytes(32).toString('hex'); + const result = await pool.query( + `INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING expires_at`, + [tenantId, body.email, body.role, token, userId], + ); + + return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at }); + }); + + app.get('/api/v1/auth/invites', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + + const result = await pool.query( + `SELECT id, email, role, expires_at, created_at FROM tenant_invites + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [tenantId], + ); + + return { invites: result.rows }; + }); + + app.delete('/api/v1/auth/invites/:id', async (req, reply) => { + if (!requireRole(req, reply, 'admin')) return; + const tenantId = (req as any).tenantId; + const { id } = req.params as { id: string }; + + const result = await pool.query( + `DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`, + [id, tenantId], + ); + + if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' }); + return { deleted: true }; + }); } diff --git a/products/06-runbook-automation/saas/src/config/index.ts b/products/06-runbook-automation/saas/src/config/index.ts index 7609fd6..60a51c6 100644 --- a/products/06-runbook-automation/saas/src/config/index.ts +++ b/products/06-runbook-automation/saas/src/config/index.ts @@ -7,7 +7,7 @@ const envSchema = z.object({ JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'), SLACK_BOT_TOKEN: z.string().optional(), SLACK_SIGNING_SECRET: z.string().optional(), - CORS_ORIGIN: z.string().default('*'), + CORS_ORIGIN: z.string().default('http://localhost:5173'), LOG_LEVEL: z.string().default('info'), }); diff --git a/products/06-runbook-automation/saas/src/data/db.ts b/products/06-runbook-automation/saas/src/data/db.ts index 85cd7f5..f95466d 100644 --- a/products/06-runbook-automation/saas/src/data/db.ts +++ b/products/06-runbook-automation/saas/src/data/db.ts @@ -4,8 +4,12 @@ import { config } from '../config/index.js'; const logger = pino({ name: 'data' }); -export const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); +/** + * RLS tenant isolation wrapper. + * Sets `app.tenant_id` for the duration of the callback, then resets. + */ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient) => Promise): Promise { const client = await pool.connect(); try { @@ -22,3 +26,15 @@ export async function withTenant(tenantId: string, fn: (client: pg.PoolClient client.release(); } } + +/** System-level queries that intentionally bypass RLS (auth, migrations, health) */ +export async function systemQuery( + text: string, params?: any[] +): Promise> { + return pool.query(text, params); +} + +/** For auth middleware that needs direct pool access for API key lookups */ +export function getPoolForAuth(): pg.Pool { + return pool; +} diff --git a/products/06-runbook-automation/saas/src/index.ts b/products/06-runbook-automation/saas/src/index.ts index f04c888..87bf902 100644 --- a/products/06-runbook-automation/saas/src/index.ts +++ b/products/06-runbook-automation/saas/src/index.ts @@ -3,8 +3,8 @@ import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import pino from 'pino'; import { config } from './config/index.js'; -import { pool } from './data/db.js'; -import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; +import { getPoolForAuth } from './data/db.js'; +import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js'; import { registerRunbookRoutes } from './api/runbooks.js'; import { registerApprovalRoutes } from './api/approvals.js'; import { registerSlackRoutes } from './slackbot/handler.js'; @@ -16,16 +16,25 @@ const app = Fastify({ logger: true }); await app.register(cors, { origin: config.CORS_ORIGIN }); await app.register(helmet); -registerAuth(app, config.JWT_SECRET, pool); +const pool = getPoolForAuth(); +decorateAuth(app); +// Public routes (no auth) app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' })); app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' })); - -registerAuthRoutes(app, config.JWT_SECRET, pool); -registerRunbookRoutes(app); -registerApprovalRoutes(app); registerSlackRoutes(app); +// Auth routes (public - login/signup) +registerAuthRoutes(app, config.JWT_SECRET, pool); + +// Protected routes (auth required) +app.register(async function protectedRoutes(protectedApp) { + protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool)); + registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool); + registerRunbookRoutes(protectedApp); + registerApprovalRoutes(protectedApp); +}); + try { await app.listen({ port: config.PORT, host: '0.0.0.0' }); logger.info({ port: config.PORT }, 'dd0c/run SaaS started'); @@ -33,6 +42,3 @@ try { logger.fatal(err, 'Failed to start'); process.exit(1); } -// Build: 2026-03-01T06:43:58Z -// CI: 2026-03-01T06:52:14Z -// CI fix: 06:56 diff --git a/products/06-runbook-automation/saas/src/slackbot/handler.ts b/products/06-runbook-automation/saas/src/slackbot/handler.ts index 144daee..d03e148 100644 --- a/products/06-runbook-automation/saas/src/slackbot/handler.ts +++ b/products/06-runbook-automation/saas/src/slackbot/handler.ts @@ -48,8 +48,8 @@ export function registerSlackRoutes(app: FastifyInstance) { if (actionType === 'approve_step' && stepId) { // Look up the audit entry to get tenant + execution context - const { pool } = await import('../data/db.js'); - const entry = await pool.query( + const { systemQuery } = await import('../data/db.js'); + const entry = await systemQuery( `SELECT ae.id, ae.execution_id, e.tenant_id FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, @@ -71,8 +71,8 @@ export function registerSlackRoutes(app: FastifyInstance) { logger.info({ stepId, user: slackUserId }, 'Step approved via Slack'); } } else if (actionType === 'reject_step' && stepId) { - const { pool } = await import('../data/db.js'); - const entry = await pool.query( + const { systemQuery } = await import('../data/db.js'); + const entry = await systemQuery( `SELECT ae.id, ae.execution_id, e.tenant_id FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`, diff --git a/products/SECURITY-ARCHITECTURE-PLAN.md b/products/SECURITY-ARCHITECTURE-PLAN.md new file mode 100644 index 0000000..2f6c184 --- /dev/null +++ b/products/SECURITY-ARCHITECTURE-PLAN.md @@ -0,0 +1,97 @@ +# ARCHITECTURE SPEC & IMPLEMENTATION PLAN +**Project:** dd0c DevOps SaaS Platform +**Author:** Dr. Quinn, BMad Master Problem Solver +**Date:** 2026-03-02 + +## 1. ROOT CAUSE ANALYSIS + +We have systematically analyzed the 10 adversarial review findings and grouped them by their structural root causes. + +### Group A: Middleware & Request Pipeline Integrity (Issues 1, 7, 8, 9, 10) +* **Root Cause:** The authentication middleware is implemented as a global `app.addHook('onRequest')` using brittle string manipulation (`.startsWith()`) to bypass public routes. Fastify’s strength lies in its plugin encapsulation model, which is completely bypassed here. Furthermore, foundational web security controls (CORS scoping, Rate Limiting, Request Validation via schema) are missing, and JWT verification lacks algorithm strictness, opening the door to algorithm confusion. + +### Group B: Tenant Isolation & Data Security (Issues 2, 3) +* **Root Cause:** The system's multi-tenancy model is incomplete. + * **Data Leakage Risk:** The Postgres `pool` object is exported directly from `db.ts`. Even though `withTenant()` exists to enforce Row-Level Security (RLS) via `SET LOCAL`, exporting `pool` means developers can inadvertently bypass RLS by calling `pool.query()` directly. + * **Product Gap:** The data model strictly creates a 1:1 mapping between a new signup and a new tenant. There is no relational entity (e.g., `tenant_invites`) to securely map a new user to an existing tenant, breaking the "built for teams" promise. + +### Group C: Infrastructure Configuration Secrets (Issues 5, 6) +* **Root Cause:** Infrastructure-as-Code (Docker Compose) is being used as a secrets manager. All 5 services authenticate to Postgres using the root `dd0c` superuser, and secrets (`JWT_SECRET`, passwords) are hardcoded into the compose YAML. If one service is compromised (e.g., via SQLi), the attacker gains root access to all databases. + +### Group D: Architectural Reliability Constraints (Issue 4) +* **Root Cause:** Webhooks operate synchronously on containers configured to scale-to-zero. External providers (PagerDuty, Datadog) have strict timeout thresholds (typically 5-10s). Fly.io container cold-starts often exceed these thresholds, causing providers to drop payloads before the container can awaken and process the request. + +--- + +## 2. ARCHITECTURE DECISIONS + +Considering the constraints of a solo founder, a current NAS deployment with a path to Fly.io, and the need to preserve existing tests, we will adopt the following architectural standards: + +1. **Fastify Plugin Encapsulation (Fixes #1, #10):** We will stop using global hooks. Public routes (health, webhooks) will be registered on the main `app` instance. Authenticated routes will be registered inside an encapsulated Fastify plugin where the auth hook is applied safely without string checking. We will use `@fastify/type-provider-zod` for built-in, strict request schema validation. +2. **Strict Data Access Module (Fixes #3):** The `db.ts` file will no longer export the raw `pool`. It will export a `db` object containing exactly two methods: `withTenant(tenantId, callback)` for business logic, and `systemQuery(query)` explicitly marked and audited for system-level background tasks. +3. **Config-Driven Secrets & Least Privilege DB Roles (Fixes #5, #6):** `docker-compose.yml` will be scrubbed of secrets. Secrets will be loaded via a `.env` file. We will update `01-init-db.sh` to create service-specific PostgreSQL users with access *only* to their respective databases. +4. **Invite-Based Onboarding (Fixes #2):** We will introduce a `tenant_invites` table. The signup flow will be modified: if a user signs up with a valid invite token, they bypass tenant creation and are appended to the existing tenant with the defined role. +5. **Decoupled Webhook Ingestion (Fixes #4):** To support scale-to-zero without losing webhooks, we will leverage the already-running Upstash Redis instance. Webhooks will hit a lightweight, non-scaling ingestion function (or the Rust `route-proxy` which is always on) that simply `LPUSH`es payloads to Redis. The main Node services can safely wake up, `BRPOP` from the queue, and process asynchronously. + +--- + +## 3. IMPLEMENTATION PLAN + +This plan is phased. Existing tests MUST be run (`./integration-test.sh`) after each phase. + +### Phase 1: Security Critical (Do this before production) + +**Task 1.1: Fastify Auth Encapsulation** +* **Change:** Modify `shared/auth.ts` and `03-alert-intelligence/src/auth/middleware.ts`. Remove `app.addHook('onRequest')`. Export a Fastify plugin `export const requireAuth = fp(async (fastify) => { fastify.addHook(...) })`. +* **Change:** In `index.ts`, register public routes first. Then register the `requireAuth` plugin, then register protected routes. +* **Effort:** Medium +* **Risk if deferred:** High (Auth bypass easily discoverable by automated scanners). + +**Task 1.2: Hardened JWT Validation** +* **Change:** In `auth/middleware.ts`, update `jwt.verify(token, jwtSecret)` to `jwt.verify(token, jwtSecret, { algorithms: ['HS256'] })`. +* **Effort:** Small + +**Task 1.3: Restrict Database Pool Access** +* **Change:** In `saas/src/data/db.ts` (and shared equivalents), remove `export const pool`. Wrap queries in a strictly exported `systemQuery` function, and enforce `withTenant` for the rest. Update all services relying on `pool.query()` to use the new paradigm. +* **Effort:** Medium +* **Risk if deferred:** Critical (Any developer mistake results in massive cross-tenant data leakage). + +### Phase 2: Architecture (Structural Readiness) + +**Task 2.1: Secrets Management & DB Roles** +* **Change:** Update `docker-compose.yml` to use variable substitution (e.g., `${POSTGRES_PASSWORD}`). Provide an `.env.example`. +* **Change:** Update `docker-init-db.sh` to execute `CREATE USER dd0c_alert WITH PASSWORD '...'; GRANT ALL ON DATABASE dd0c_alert TO dd0c_alert;`. Update services to use their designated credentials. +* **Effort:** Medium +* **Dependencies:** None. + +**Task 2.2: Rate Limiting, CORS, and Zod Integration** +* **Change:** Install `@fastify/rate-limit`. Apply a strict limit (e.g., 5 requests/min) to `/api/v1/auth/*`. +* **Change:** In `config/index.ts`, enforce `CORS_ORIGIN` using Zod regex (e.g., `^https?://.*\.dd0c\.localhost$`). +* **Change:** Integrate `@fastify/type-provider-zod` into route definitions to reject bad payloads at the Fastify schema level. +* **Effort:** Medium + +### Phase 3: Product (Feature Blockers) + +**Task 3.1: Team Invite Flow** +* **Change:** Create a new migration for `tenant_invites (id, tenant_id, email, token, role, expires_at)`. +* **Change:** Add `POST /api/v1/auth/invite` (generates token) and update `POST /api/v1/auth/signup` to accept an optional `invite_token`. +* **Effort:** Large +* **Dependencies:** Database migrations. + +**Task 3.2: Async Webhook Ingestion** +* **Change:** Shift webhook endpoints to simply validate signatures and `LPUSH` the raw payload to Redis. +* **Change:** Create a background worker loop in the Node service that uses `BRPOP` to pull and process webhooks. (Alternatively, route webhooks through the constantly-running Rust proxy). +* **Effort:** Large +* **Risk if deferred:** Medium (External webhook timeouts on Fly.io scale-to-zero). + +--- + +## 4. TESTING STRATEGY + +To verify fixes without breaking the 27 integration tests: + +1. **Auth Bypass:** Write a new test in the smoke suite that attempts to hit `/api/v1/auth/login-hack` and `/webhooks/../api/protected`. Expect `404 Not Found` or `401 Unauthorized`. +2. **RLS Protection:** After restricting `pool`, run `./integration-test.sh`. Any query that was improperly bypassing `withTenant` will cause TypeScript compilation to fail (since `pool.query` is no longer available), ensuring safe refactoring. +3. **DB Roles:** Spin up a clean docker-compose environment. Use a Postgres client to verify that `dd0c_alert` user cannot run `SELECT * FROM dd0c_route.users`. +4. **Webhooks:** Simulate a Fly.io cold start by pausing the `dd0c-alert` container, firing a webhook to the ingestion endpoint, and verifying the payload is queued in Redis and processed upon container resume. +5. **Invite Flow:** Add a multi-user flow to `integration-test.sh` asserting User B can be invited by User A and both can query the same `tenant_id` records. \ No newline at end of file diff --git a/products/build-push.sh b/products/build-push.sh index f302f1c..34091cc 100755 --- a/products/build-push.sh +++ b/products/build-push.sh @@ -43,7 +43,7 @@ else fi # Login to registry -echo "secret" | docker login "$REGISTRY" --username dd0c --password-stdin 2>/dev/null || true +echo "${REGISTRY_PASSWORD:-secret}" | docker login "$REGISTRY" --username dd0c --password-stdin 2>/dev/null || true echo -e "${YELLOW}dd0c Build & Push — $(date -u '+%Y-%m-%d %H:%M UTC')${NC}" echo -e "Registry: ${REGISTRY}\n" diff --git a/products/console/src/modules/alert/AlertDashboard.tsx b/products/console/src/modules/alert/AlertDashboard.tsx new file mode 100644 index 0000000..3a80017 --- /dev/null +++ b/products/console/src/modules/alert/AlertDashboard.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Badge } from '../../shared/Badge'; +import { Button } from '../../shared/Button'; +import { EmptyState } from '../../shared/EmptyState'; +import { + fetchIncidentSummary, + fetchIncidents, + type Incident, + type IncidentSummary, + type Severity, + type IncidentStatus, +} from './api'; + +const severityColor: Record = { + critical: 'red', + high: 'yellow', + warning: 'yellow', + info: 'blue', +}; + +// Use orange-ish for 'high' — override via custom class since Badge only has named colors +// We'll map high→yellow and warning→yellow but label them differently +const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => { + if (s === 'critical') return 'red'; + if (s === 'high' || s === 'warning') return 'yellow'; + return 'blue'; +}; + +const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => { + if (s === 'open') return 'red'; + if (s === 'acknowledged') return 'cyan'; + return 'green'; +}; + +function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +export function AlertDashboard() { + const navigate = useNavigate(); + const [summary, setSummary] = useState(null); + const [incidents, setIncidents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + Promise.all([fetchIncidentSummary(), fetchIncidents()]) + .then(([sum, inc]) => { + if (!cancelled) { + setSummary(sum); + setIncidents(inc); + } + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + const columns = [ + { + key: 'severity', + header: 'Severity', + sortable: true, + render: (row: Incident) => ( + + {row.severity} + + ), + }, + { + key: 'title', + header: 'Incident', + sortable: true, + render: (row: Incident) => ( + {row.title} + ), + }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (row: Incident) => ( + + {row.status} + + ), + }, + { + key: 'source', + header: 'Source', + sortable: true, + render: (row: Incident) => ( + {row.source} + ), + }, + { + key: 'count', + header: 'Count', + sortable: true, + render: (row: Incident) => ( + {row.count} + ), + }, + { + key: 'lastSeen', + header: 'Last Seen', + sortable: true, + render: (row: Incident) => ( + {formatTime(row.lastSeen)} + ), + }, + ]; + + return ( +
+ +
+ + +
+
+ + {/* Summary stats */} + {!loading && !error && summary && ( +
+ {[ + { label: 'Total Incidents', value: summary.total, color: 'text-white' }, + { label: 'Open', value: summary.open, color: 'text-red-400' }, + { label: 'Acknowledged', value: summary.acknowledged, color: 'text-cyan-400' }, + { label: 'Resolved', value: summary.resolved, color: 'text-emerald-400' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ )} + + + {loading && ( +
+
+ + + + + Loading incidents… +
+
+ )} + + {error && ( +
+
+ Failed to load incidents: {error} +
+
+ )} + + {!loading && !error && incidents.length === 0 && ( + + )} + + {!loading && !error && incidents.length > 0 && ( + row.id} + onRowClick={(row) => navigate(`/alert/incidents/${row.id}`)} + /> + )} + + + ); +} diff --git a/products/console/src/modules/alert/AlertDetail.tsx b/products/console/src/modules/alert/AlertDetail.tsx new file mode 100644 index 0000000..efa5fd4 --- /dev/null +++ b/products/console/src/modules/alert/AlertDetail.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Badge } from '../../shared/Badge'; +import { Button } from '../../shared/Button'; +import { EmptyState } from '../../shared/EmptyState'; +import { + fetchIncident, + updateIncidentStatus, + type IncidentDetail as IncidentDetailType, + type Severity, + type IncidentStatus, +} from './api'; + +const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => { + if (s === 'critical') return 'red'; + if (s === 'high' || s === 'warning') return 'yellow'; + return 'blue'; +}; + +const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => { + if (s === 'open') return 'red'; + if (s === 'acknowledged') return 'cyan'; + return 'green'; +}; + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString(); +} + +export function AlertDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [incident, setIncident] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + if (!id) return; + let cancelled = false; + setLoading(true); + fetchIncident(id) + .then((data) => { + if (!cancelled) setIncident(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [id]); + + const handleStatusChange = async (status: IncidentStatus) => { + if (!id || !incident) return; + setUpdating(true); + try { + await updateIncidentStatus(id, status); + setIncident({ ...incident, status }); + } catch (err: any) { + setError(err.message); + } finally { + setUpdating(false); + } + }; + + if (loading) { + return ( +
+
+ + + + + Loading incident… +
+
+ ); + } + + if (error) { + return ( +
+
+ Failed to load incident: {error} +
+ +
+ ); + } + + if (!incident) { + return ( + + ); + } + + return ( +
+ + + + + {/* Metadata */} + +
+
+

Severity

+
+ {incident.severity} +
+
+
+

Status

+
+ {incident.status} +
+
+
+

Source

+

{incident.source}

+
+
+

Alert Count

+

{incident.count}

+
+
+

Fingerprint

+

{incident.fingerprint}

+
+
+

First Seen

+

{formatTimestamp(incident.firstSeen)}

+
+
+

Last Seen

+

{formatTimestamp(incident.lastSeen)}

+
+
+
+ + {/* Status actions */} +
+ {incident.status !== 'acknowledged' && ( + + )} + {incident.status !== 'resolved' && ( + + )} + {incident.status === 'resolved' && ( + + )} +
+ + {/* Alert timeline */} +

Alert Timeline

+ + {incident.alerts.length === 0 ? ( + + ) : ( +
+ {incident.alerts.map((alert) => ( +
+
+
+
+
+

{alert.message}

+
+ {formatTimestamp(alert.timestamp)} + via {alert.source} +
+ {alert.labels && Object.keys(alert.labels).length > 0 && ( +
+ {Object.entries(alert.labels).map(([k, v]) => ( + + {k}={v} + + ))} +
+ )} +
+
+ ))} +
+ )} + +
+ ); +} diff --git a/products/console/src/modules/alert/NotificationConfig.tsx b/products/console/src/modules/alert/NotificationConfig.tsx new file mode 100644 index 0000000..c923700 --- /dev/null +++ b/products/console/src/modules/alert/NotificationConfig.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Button } from '../../shared/Button'; +import { + fetchNotificationConfig, + updateNotificationConfig, + type NotificationSettings, + type Severity, +} from './api'; + +const severityOptions: Severity[] = ['critical', 'high', 'warning', 'info']; + +export function NotificationConfig() { + const navigate = useNavigate(); + const [config, setConfig] = useState({ + webhook_url: '', + slack_channel: '', + email: '', + pagerduty_key: '', + severity_threshold: 'warning', + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchNotificationConfig() + .then((data) => { + if (!cancelled) setConfig(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + const handleSave = async () => { + setSaving(true); + setError(null); + setSuccess(false); + try { + await updateNotificationConfig(config); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const updateField = (field: keyof NotificationSettings, value: string) => { + setConfig((prev) => ({ ...prev, [field]: value })); + }; + + if (loading) { + return ( +
+
+ + + + + Loading configuration… +
+
+ ); + } + + return ( +
+ + + + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Configuration saved successfully. +
+ )} + + +
+
+ + updateField('webhook_url', e.target.value)} + placeholder="https://hooks.example.com/alerts" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + updateField('slack_channel', e.target.value)} + placeholder="#incidents" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + updateField('email', e.target.value)} + placeholder="oncall@example.com" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ + updateField('pagerduty_key', e.target.value)} + placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono" + /> +
+ +
+ +

Only notify for incidents at or above this severity.

+
+ {severityOptions.map((sev) => ( + + ))} +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/products/console/src/modules/alert/WebhookSecrets.tsx b/products/console/src/modules/alert/WebhookSecrets.tsx new file mode 100644 index 0000000..6963cee --- /dev/null +++ b/products/console/src/modules/alert/WebhookSecrets.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Button } from '../../shared/Button'; +import { Modal } from '../../shared/Modal'; +import { EmptyState } from '../../shared/EmptyState'; +import { + fetchWebhookSecrets, + upsertWebhookSecret, + deleteWebhookSecret, + type WebhookSecret, +} from './api'; + +const knownProviders = ['Datadog', 'PagerDuty', 'OpsGenie', 'Grafana']; + +function maskSecret(secret: string): string { + if (secret.length <= 8) return '••••••••'; + return secret.slice(0, 4) + '••••••••' + secret.slice(-4); +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString(); +} + +export function WebhookSecrets() { + const navigate = useNavigate(); + const [secrets, setSecrets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editProvider, setEditProvider] = useState(''); + const [editSecret, setEditSecret] = useState(''); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + + const loadSecrets = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchWebhookSecrets(); + setSecrets(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadSecrets(); + }, []); + + const openAdd = () => { + setEditProvider(''); + setEditSecret(''); + setModalOpen(true); + }; + + const openEdit = (ws: WebhookSecret) => { + setEditProvider(ws.provider); + setEditSecret(''); + setModalOpen(true); + }; + + const handleSave = async () => { + if (!editProvider.trim() || !editSecret.trim()) return; + setSaving(true); + try { + await upsertWebhookSecret(editProvider.trim(), editSecret.trim()); + setModalOpen(false); + await loadSecrets(); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (provider: string) => { + setDeleting(provider); + try { + await deleteWebhookSecret(provider); + await loadSecrets(); + } catch (err: any) { + setError(err.message); + } finally { + setDeleting(null); + } + }; + + const columns = [ + { + key: 'provider', + header: 'Provider', + sortable: true, + render: (row: WebhookSecret) => ( + {row.provider} + ), + }, + { + key: 'secret', + header: 'Secret', + render: (row: WebhookSecret) => ( + {maskSecret(row.secret)} + ), + }, + { + key: 'updated_at', + header: 'Updated', + sortable: true, + render: (row: WebhookSecret) => ( + {formatTimestamp(row.updated_at)} + ), + }, + { + key: 'actions', + header: '', + render: (row: WebhookSecret) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+ +
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + + {loading && ( +
+
+ + + + + Loading secrets… +
+
+ )} + + {!loading && !error && secrets.length === 0 && ( + + )} + + {!loading && !error && secrets.length > 0 && ( +
row.provider} + /> + )} + + + {/* Add/Edit Modal */} + setModalOpen(false)} title={editProvider ? `Edit ${editProvider} Secret` : 'Add Webhook Secret'}> +
+
+ + {editProvider ? ( +

{editProvider}

+ ) : ( +
+
+ {knownProviders.map((p) => ( + + ))} +
+ setEditProvider(e.target.value)} + placeholder="Or type a custom provider name" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ )} +
+ +
+ + setEditSecret(e.target.value)} + placeholder="Enter signing secret" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono" + /> +
+ +
+ + +
+
+
+ + ); +} diff --git a/products/console/src/modules/alert/api.ts b/products/console/src/modules/alert/api.ts new file mode 100644 index 0000000..70da212 --- /dev/null +++ b/products/console/src/modules/alert/api.ts @@ -0,0 +1,101 @@ +import { apiFetch } from '../../shell/api'; + +// --- Types --- + +export interface IncidentSummary { + total: number; + open: number; + acknowledged: number; + resolved: number; +} + +export type Severity = 'critical' | 'high' | 'warning' | 'info'; +export type IncidentStatus = 'open' | 'acknowledged' | 'resolved'; + +export interface Incident { + id: string; + title: string; + severity: Severity; + status: IncidentStatus; + source: string; + fingerprint: string; + firstSeen: string; + lastSeen: string; + count: number; +} + +export interface IncidentDetail extends Incident { + alerts: AlertEvent[]; +} + +export interface AlertEvent { + id: string; + timestamp: string; + source: string; + message: string; + labels?: Record; +} + +export interface NotificationSettings { + webhook_url: string; + slack_channel: string; + email: string; + pagerduty_key: string; + severity_threshold: Severity; +} + +export interface WebhookSecret { + provider: string; + secret: string; + created_at: string; + updated_at: string; +} + +// --- API calls --- + +export async function fetchIncidentSummary(): Promise { + return apiFetch('/api/v1/incidents/summary'); +} + +export async function fetchIncidents(): Promise { + return apiFetch('/api/v1/incidents'); +} + +export async function fetchIncident(id: string): Promise { + return apiFetch(`/api/v1/incidents/${encodeURIComponent(id)}`); +} + +export async function updateIncidentStatus(id: string, status: IncidentStatus): Promise { + await apiFetch(`/api/v1/incidents/${encodeURIComponent(id)}/status`, { + method: 'PUT', + body: JSON.stringify({ status }), + }); +} + +export async function fetchNotificationConfig(): Promise { + return apiFetch('/api/v1/notifications/config'); +} + +export async function updateNotificationConfig(config: NotificationSettings): Promise { + await apiFetch('/api/v1/notifications/config', { + method: 'PUT', + body: JSON.stringify(config), + }); +} + +export async function fetchWebhookSecrets(): Promise { + return apiFetch('/api/v1/webhooks/secrets'); +} + +export async function upsertWebhookSecret(provider: string, secret: string): Promise { + await apiFetch('/api/v1/webhooks/secrets', { + method: 'PUT', + body: JSON.stringify({ provider, secret }), + }); +} + +export async function deleteWebhookSecret(provider: string): Promise { + await apiFetch(`/api/v1/webhooks/secrets/${encodeURIComponent(provider)}`, { + method: 'DELETE', + }); +} diff --git a/products/console/src/modules/alert/manifest.tsx b/products/console/src/modules/alert/manifest.tsx new file mode 100644 index 0000000..4f9606a --- /dev/null +++ b/products/console/src/modules/alert/manifest.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { ModuleManifest } from '../drift/manifest.js'; +import { AlertDashboard } from './AlertDashboard'; +import { AlertDetail } from './AlertDetail'; +import { NotificationConfig } from './NotificationConfig'; +import { WebhookSecrets } from './WebhookSecrets'; + +export const alertManifest: ModuleManifest = { + id: 'alert', + name: 'Alert Intelligence', + icon: '🔔', + path: '/alert', + entitlement: 'alert', + routes: [ + { path: 'alert', element: }, + { path: 'alert/incidents/:id', element: }, + { path: 'alert/notifications', element: }, + { path: 'alert/webhooks', element: }, + ], +}; diff --git a/products/console/src/modules/cost/CostBaselines.tsx b/products/console/src/modules/cost/CostBaselines.tsx new file mode 100644 index 0000000..b3e7903 --- /dev/null +++ b/products/console/src/modules/cost/CostBaselines.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Button } from '../../shared/Button'; +import { Modal } from '../../shared/Modal'; +import { EmptyState } from '../../shared/EmptyState'; +import { fetchBaselines, updateBaseline, type Baseline } from './api'; + +function formatCurrency(n: number): string { + return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +export function CostBaselines() { + const navigate = useNavigate(); + const [baselines, setBaselines] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editItem, setEditItem] = useState(null); + const [editForm, setEditForm] = useState({ monthly_budget: '', alert_threshold_pct: '' }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchBaselines() + .then((data) => { + if (!cancelled) setBaselines(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + function openEdit(b: Baseline) { + setEditItem(b); + setEditForm({ + monthly_budget: String(b.monthly_budget), + alert_threshold_pct: String(b.alert_threshold_pct), + }); + } + + async function handleSave() { + if (!editItem) return; + setSaving(true); + try { + const updated = await updateBaseline(editItem.id, { + monthly_budget: Number(editForm.monthly_budget), + alert_threshold_pct: Number(editForm.alert_threshold_pct), + }); + setBaselines((prev) => prev.map((b) => (b.id === editItem.id ? updated : b))); + setEditItem(null); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + const spendRatio = (b: Baseline) => { + if (b.monthly_budget === 0) return 0; + return Math.round((b.current_spend / b.monthly_budget) * 100); + }; + + const columns = [ + { + key: 'service', + header: 'Service', + sortable: true, + render: (row: Baseline) => ( + {row.service} + ), + }, + { + key: 'account_id', + header: 'Account', + sortable: true, + render: (row: Baseline) => ( + {row.account_id} + ), + }, + { + key: 'monthly_budget', + header: 'Budget', + sortable: true, + render: (row: Baseline) => ( + {formatCurrency(row.monthly_budget)} + ), + }, + { + key: 'current_spend', + header: 'Current Spend', + sortable: true, + render: (row: Baseline) => { + const pct = spendRatio(row); + const color = pct > 100 ? 'text-red-400' : pct > 80 ? 'text-yellow-400' : 'text-cyan-400'; + return ( + + {formatCurrency(row.current_spend)} ({pct}%) + + ); + }, + }, + { + key: 'alert_threshold_pct', + header: 'Alert At', + sortable: true, + render: (row: Baseline) => ( + {row.alert_threshold_pct}% + ), + }, + { + key: 'actions', + header: '', + render: (row: Baseline) => ( + + ), + }, + ]; + + const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors'; + const labelClass = 'block text-xs font-medium text-gray-400 mb-1'; + + return ( +
+ navigate('/cost')}>← Back} + /> + + {error && ( +
+ {error} +
+ )} + + + {loading && ( +
+
+ + + + + Loading baselines… +
+
+ )} + + {!loading && !error && baselines.length === 0 && ( + + )} + + {!loading && !error && baselines.length > 0 && ( +
row.id} + /> + )} + + + setEditItem(null)} title={`Edit Baseline: ${editItem?.service ?? ''}`}> +
+
+ + setEditForm({ ...editForm, monthly_budget: e.target.value })} + /> +
+
+ + setEditForm({ ...editForm, alert_threshold_pct: e.target.value })} + /> +
+
+ + +
+
+
+ + ); +} diff --git a/products/console/src/modules/cost/CostDashboard.tsx b/products/console/src/modules/cost/CostDashboard.tsx new file mode 100644 index 0000000..f3aa12d --- /dev/null +++ b/products/console/src/modules/cost/CostDashboard.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Badge } from '../../shared/Badge'; +import { Button } from '../../shared/Button'; +import { EmptyState } from '../../shared/EmptyState'; +import { fetchCostSummary, fetchAnomalies, acknowledgeAnomaly, type Anomaly, type CostSummary } from './api'; + +function severityColor(severity: string): 'green' | 'yellow' | 'red' | 'cyan' { + if (severity === 'critical') return 'red'; + if (severity === 'high') return 'red'; + if (severity === 'medium') return 'yellow'; + return 'green'; +} + +function statusColor(status: string): 'green' | 'yellow' | 'gray' { + if (status === 'resolved') return 'green'; + if (status === 'acknowledged') return 'yellow'; + return 'gray'; +} + +function formatCurrency(n: number): string { + return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +export function CostDashboard() { + const navigate = useNavigate(); + const [summary, setSummary] = useState(null); + const [anomalies, setAnomalies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + Promise.all([fetchCostSummary(), fetchAnomalies()]) + .then(([sum, anoms]) => { + if (!cancelled) { + setSummary(sum); + setAnomalies(anoms); + } + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + async function handleAcknowledge(id: string) { + try { + const updated = await acknowledgeAnomaly(id); + setAnomalies((prev) => prev.map((a) => (a.id === id ? updated : a))); + } catch (err: any) { + setError(err.message); + } + } + + const columns = [ + { + key: 'service', + header: 'Service', + sortable: true, + render: (row: Anomaly) => ( + {row.service} + ), + }, + { + key: 'severity', + header: 'Severity', + sortable: true, + render: (row: Anomaly) => ( + {row.severity} + ), + }, + { + key: 'delta', + header: 'Overspend', + sortable: true, + render: (row: Anomaly) => ( + + +{formatCurrency(row.delta)} ({row.delta_pct > 0 ? '+' : ''}{row.delta_pct}%) + + ), + }, + { + key: 'actual_cost', + header: 'Actual', + sortable: true, + render: (row: Anomaly) => ( + {formatCurrency(row.actual_cost)} + ), + }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (row: Anomaly) => ( +
+ {row.status} + {row.status === 'open' && ( + + )} +
+ ), + }, + { + key: 'detected_at', + header: 'Detected', + sortable: true, + render: (row: Anomaly) => ( + {formatTime(row.detected_at)} + ), + }, + ]; + + return ( +
+ + + +
+ } + /> + + {!loading && !error && summary && ( +
+ {[ + { label: 'Total Spend', value: formatCurrency(summary.total_spend), color: 'text-white' }, + { label: 'Anomalies', value: summary.anomaly_count, color: 'text-red-400' }, + { label: 'Potential Savings', value: formatCurrency(summary.potential_savings), color: 'text-cyan-400' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ )} + + + {loading && ( +
+
+ + + + + Loading cost data… +
+
+ )} + + {error && ( +
+
+ Failed to load cost data: {error} +
+
+ )} + + {!loading && !error && anomalies.length === 0 && ( + + )} + + {!loading && !error && anomalies.length > 0 && ( +
row.id} + /> + )} + + + ); +} diff --git a/products/console/src/modules/cost/GovernanceRules.tsx b/products/console/src/modules/cost/GovernanceRules.tsx new file mode 100644 index 0000000..836e0b9 --- /dev/null +++ b/products/console/src/modules/cost/GovernanceRules.tsx @@ -0,0 +1,207 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Badge } from '../../shared/Badge'; +import { Button } from '../../shared/Button'; +import { EmptyState } from '../../shared/EmptyState'; +import { fetchGovernanceRules, createGovernanceRule, deleteGovernanceRule, type GovernanceRule } from './api'; + +function actionColor(action: string): 'red' | 'yellow' | 'blue' { + if (action === 'block') return 'red'; + if (action === 'alert') return 'yellow'; + return 'blue'; +} + +export function GovernanceRules() { + const navigate = useNavigate(); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ name: '', description: '', condition: '', action: 'alert' as 'alert' | 'block' | 'tag', enabled: true }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchGovernanceRules() + .then((data) => { + if (!cancelled) setRules(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const created = await createGovernanceRule(form); + setRules((prev) => [...prev, created]); + setShowCreate(false); + setForm({ name: '', description: '', condition: '', action: 'alert', enabled: true }); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + if (!confirm('Delete this governance rule?')) return; + try { + await deleteGovernanceRule(id); + setRules((prev) => prev.filter((r) => r.id !== id)); + } catch (err: any) { + setError(err.message); + } + } + + const columns = [ + { + key: 'name', + header: 'Rule', + sortable: true, + render: (row: GovernanceRule) => ( +
+ {row.name} +

{row.description}

+
+ ), + }, + { + key: 'condition', + header: 'Condition', + render: (row: GovernanceRule) => ( + {row.condition} + ), + }, + { + key: 'action', + header: 'Action', + sortable: true, + render: (row: GovernanceRule) => ( + {row.action} + ), + }, + { + key: 'enabled', + header: 'Status', + render: (row: GovernanceRule) => ( + {row.enabled ? 'Active' : 'Disabled'} + ), + }, + { + key: 'actions', + header: '', + render: (row: GovernanceRule) => ( + + ), + }, + ]; + + const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors'; + const labelClass = 'block text-xs font-medium text-gray-400 mb-1'; + + return ( +
+ + + +
+ } + /> + + {error && ( +
+ {error} +
+ )} + + {showCreate && ( + +
+
+ + setForm({ ...form, name: e.target.value })} placeholder="max-ec2-spend" /> +
+
+ + setForm({ ...form, description: e.target.value })} placeholder="Block EC2 spend over $10k/month" /> +
+
+ + setForm({ ...form, condition: e.target.value })} placeholder="service.spend > 10000 AND service.type == 'ec2'" /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ )} + + + {loading && ( +
+
+ + + + + Loading rules… +
+
+ )} + + {!loading && !error && rules.length === 0 && ( + setShowCreate(true)} + /> + )} + + {!loading && !error && rules.length > 0 && ( +
row.id} + /> + )} + + + ); +} diff --git a/products/console/src/modules/cost/api.ts b/products/console/src/modules/cost/api.ts new file mode 100644 index 0000000..5ea9d20 --- /dev/null +++ b/products/console/src/modules/cost/api.ts @@ -0,0 +1,79 @@ +import { apiFetch } from '../../shell/api'; + +export interface Anomaly { + id: string; + service: string; + account_id: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + expected_cost: number; + actual_cost: number; + delta: number; + delta_pct: number; + detected_at: string; + status: 'open' | 'acknowledged' | 'resolved'; +} + +export interface Baseline { + id: string; + service: string; + account_id: string; + monthly_budget: number; + alert_threshold_pct: number; + current_spend: number; + updated_at: string; +} + +export interface GovernanceRule { + id: string; + name: string; + description: string; + condition: string; + action: 'alert' | 'block' | 'tag'; + enabled: boolean; + created_at: string; +} + +export interface CostSummary { + total_spend: number; + anomaly_count: number; + potential_savings: number; + month: string; +} + +export async function fetchCostSummary(): Promise { + return apiFetch('/api/v1/cost/summary'); +} + +export async function fetchAnomalies(): Promise { + return apiFetch('/api/v1/cost/anomalies'); +} + +export async function acknowledgeAnomaly(id: string): Promise { + return apiFetch(`/api/v1/cost/anomalies/${encodeURIComponent(id)}/acknowledge`, { method: 'POST' }); +} + +export async function fetchBaselines(): Promise { + return apiFetch('/api/v1/cost/baselines'); +} + +export async function updateBaseline(id: string, payload: { monthly_budget?: number; alert_threshold_pct?: number }): Promise { + return apiFetch(`/api/v1/cost/baselines/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export async function fetchGovernanceRules(): Promise { + return apiFetch('/api/v1/cost/governance'); +} + +export async function createGovernanceRule(payload: Omit): Promise { + return apiFetch('/api/v1/cost/governance', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function deleteGovernanceRule(id: string): Promise { + await apiFetch(`/api/v1/cost/governance/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} diff --git a/products/console/src/modules/cost/manifest.tsx b/products/console/src/modules/cost/manifest.tsx new file mode 100644 index 0000000..53c6b42 --- /dev/null +++ b/products/console/src/modules/cost/manifest.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import type { RouteObject } from 'react-router-dom'; +import { CostDashboard } from './CostDashboard'; +import { CostBaselines } from './CostBaselines'; +import { GovernanceRules } from './GovernanceRules'; +import type { ModuleManifest } from '../drift/manifest'; + +export const costManifest: ModuleManifest = { + id: 'cost', + name: 'Cost Anomaly', + icon: '💰', + path: '/cost', + entitlement: 'cost', + routes: [ + { path: 'cost', element: }, + { path: 'cost/baselines', element: }, + { path: 'cost/governance', element: }, + ], +}; diff --git a/products/console/src/modules/portal/PortalDashboard.tsx b/products/console/src/modules/portal/PortalDashboard.tsx new file mode 100644 index 0000000..29c90d7 --- /dev/null +++ b/products/console/src/modules/portal/PortalDashboard.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Table } from '../../shared/Table'; +import { Badge } from '../../shared/Badge'; +import { Button } from '../../shared/Button'; +import { EmptyState } from '../../shared/EmptyState'; +import { fetchServices, type Service } from './api'; + +function tierColor(tier: string): 'red' | 'yellow' | 'cyan' { + if (tier === 'critical') return 'red'; + if (tier === 'standard') return 'yellow'; + return 'cyan'; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +export function PortalDashboard() { + const navigate = useNavigate(); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + + useEffect(() => { + let cancelled = false; + fetchServices() + .then((data) => { + if (!cancelled) setServices(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + const filtered = services.filter((s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.owner.toLowerCase().includes(search.toLowerCase()) || + s.language.toLowerCase().includes(search.toLowerCase()) + ); + + const totalServices = services.length; + const criticalCount = services.filter((s) => s.tier === 'critical').length; + const standardCount = services.filter((s) => s.tier === 'standard').length; + const languages = new Set(services.map((s) => s.language)).size; + + const columns = [ + { + key: 'name', + header: 'Service', + sortable: true, + render: (row: Service) => ( + {row.name} + ), + }, + { + key: 'owner', + header: 'Owner', + sortable: true, + render: (row: Service) => ( + {row.owner} + ), + }, + { + key: 'tier', + header: 'Tier', + sortable: true, + render: (row: Service) => ( + {row.tier} + ), + }, + { + key: 'language', + header: 'Language', + sortable: true, + render: (row: Service) => ( + {row.language} + ), + }, + { + key: 'last_updated', + header: 'Last Updated', + sortable: true, + render: (row: Service) => ( + {formatTime(row.last_updated)} + ), + }, + ]; + + return ( +
+ navigate('/portal/create')}>+ New Service + } + /> + + {!loading && !error && services.length > 0 && ( +
+ {[ + { label: 'Total Services', value: totalServices, color: 'text-white' }, + { label: 'Critical', value: criticalCount, color: 'text-red-400' }, + { label: 'Standard', value: standardCount, color: 'text-yellow-400' }, + { label: 'Languages', value: languages, color: 'text-cyan-400' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ )} + + {!loading && !error && services.length > 0 && ( +
+ setSearch(e.target.value)} + className="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ )} + + + {loading && ( +
+
+ + + + + Loading services… +
+
+ )} + + {error && ( +
+
+ Failed to load services: {error} +
+
+ )} + + {!loading && !error && services.length === 0 && ( + navigate('/portal/create')} + /> + )} + + {!loading && !error && filtered.length > 0 && ( +
row.id} + onRowClick={(row) => navigate(`/portal/${row.id}`)} + /> + )} + + {!loading && !error && services.length > 0 && filtered.length === 0 && ( + + )} + + + ); +} diff --git a/products/console/src/modules/portal/ServiceCreate.tsx b/products/console/src/modules/portal/ServiceCreate.tsx new file mode 100644 index 0000000..2a45298 --- /dev/null +++ b/products/console/src/modules/portal/ServiceCreate.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageHeader } from '../../shared/PageHeader'; +import { Card } from '../../shared/Card'; +import { Button } from '../../shared/Button'; +import { createService } from './api'; + +export function ServiceCreate() { + const navigate = useNavigate(); + const [form, setForm] = useState({ + name: '', + description: '', + owner: '', + tier: 'standard', + language: '', + repo_url: '', + docs_url: '', + tags: '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const created = await createService({ + name: form.name, + description: form.description, + owner: form.owner, + tier: form.tier, + language: form.language, + repo_url: form.repo_url || undefined, + docs_url: form.docs_url || undefined, + tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), + }); + navigate(`/portal/${created.id}`); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors'; + const labelClass = 'block text-xs font-medium text-gray-400 mb-1'; + + return ( +
+ navigate('/portal')}>← Back} + /> + + {error && ( +
+ {error} +
+ )} + + +
+
+ + setForm({ ...form, name: e.target.value })} placeholder="my-service" /> +
+
+ +