diff --git a/packages/iios-service/scripts/smoke-capability.mjs b/packages/iios-service/scripts/smoke-capability.mjs new file mode 100644 index 0000000..bb9545f --- /dev/null +++ b/packages/iios-service/scripts/smoke-capability.mjs @@ -0,0 +1,61 @@ +// P9 slice-1 smoke: the Capability Broker as governed egress. +// (a) sandbox egress SENT via the broker (WEBHOOK, no network); +// (b) REAL egress via the env-gated HttpProvider → a LOCAL echo server (EMAIL); +// (c) idempotent replay; (d) /health shows provider bindings. +// Requires the service started with IIOS_PROVIDER_URL_EMAIL=http://127.0.0.1:4599/echo. +import 'dotenv/config'; +import http from 'node:http'; +import jwt from 'jsonwebtoken'; + +const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200'; +const APP_ID = 'portal-demo'; +const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID]; +const token = jwt.sign({ sub: 'egress', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); +const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; + +async function send(channelType, target, payload, idempotencyKey) { + const r = await fetch(`${SERVICE}/v1/adapters/${channelType}/send`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ target, payload, idempotencyKey }), + }); + if (!r.ok) throw new Error(`send ${channelType} ${r.status}: ${await r.text()}`); + return r.json(); +} + +// Local echo server the real HttpProvider will POST to (no external network). +const received = []; +const echo = http.createServer((req, res) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { try { received.push(JSON.parse(b || '{}')); } catch { received.push({}); } res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); }); +}); +await new Promise((r) => echo.listen(4599, '127.0.0.1', r)); + +try { + // (a) sandbox egress through the broker + const sandboxCmd = await send('WEBHOOK', 'user@x', { text: 'hi via broker' }, `cap-sb-${Date.now()}`); + assert(sandboxCmd.status === 'SENT' && String(sandboxCmd.providerRef).startsWith('sim-'), 'sandbox egress SENT through the broker (no network)'); + + // (b) real egress via env-gated HttpProvider → local echo server + const before = received.length; + const httpCmd = await send('EMAIL', 'buyer@yamuna', { text: 'real egress proof' }, `cap-http-${Date.now()}`); + assert(httpCmd.status === 'SENT' && String(httpCmd.providerRef).startsWith('http-'), 'EMAIL egress routed to the real HttpProvider'); + assert(received.length === before + 1, 'local echo server actually received the POST (real egress, no external network)'); + assert(received[received.length - 1]?.payload?.text === 'real egress proof', 'echo received the exact payload'); + + // (c) idempotent replay → one command + const key = `cap-idem-${Date.now()}`; + const a = await send('EMAIL', 'buyer@yamuna', { text: 'x' }, key); + const b = await send('EMAIL', 'buyer@yamuna', { text: 'x' }, key); + assert(a.id === b.id, 'idempotent replay → one command'); + + // (d) /health shows provider bindings + const health = await (await fetch(`${SERVICE}/health`)).json(); + assert(health.egressProviders?.EMAIL === 'http' && health.egressProviders?.WEBHOOK === 'sandbox', '/health shows EMAIL=http, WEBHOOK=sandbox bindings'); + + console.log('\nP9 capability smoke: PASS'); +} finally { + echo.close(); +} +process.exit(0); diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 8c94c65..a0e987a 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -12,6 +12,7 @@ import { AdaptersModule } from './adapters/adapters.module'; import { RoutingModule } from './routing/routing.module'; import { AiModule } from './ai/ai.module'; import { CalendarModule } from './calendar/calendar.module'; +import { CapabilityModule } from './capability/capability.module'; import { HealthController } from './health.controller'; import { DevController } from './dev/dev.controller'; @@ -30,6 +31,7 @@ import { DevController } from './dev/dev.controller'; RoutingModule, AiModule, CalendarModule, + CapabilityModule, ], controllers: [HealthController, DevController], }) diff --git a/packages/iios-service/src/health.controller.ts b/packages/iios-service/src/health.controller.ts index c2c565c..c82aa40 100644 --- a/packages/iios-service/src/health.controller.ts +++ b/packages/iios-service/src/health.controller.ts @@ -1,12 +1,16 @@ import { Controller, Get } from '@nestjs/common'; import { PrismaService } from './prisma/prisma.service'; +import { CapabilityProviderRegistry } from './capability/capability.registry'; @Controller('health') export class HealthController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly providers: CapabilityProviderRegistry, + ) {} @Get() - async check(): Promise<{ status: string; db: boolean }> { + async check(): Promise<{ status: string; db: boolean; egressProviders: Record }> { let db = false; try { await this.prisma.$queryRaw`SELECT 1`; @@ -14,6 +18,7 @@ export class HealthController { } catch { db = false; } - return { status: 'ok', db }; + // Ops view: which provider (sandbox vs http) backs each egress channel. + return { status: 'ok', db, egressProviders: this.providers.bindings() }; } }