diff --git a/apps/adapter-inspector/index.html b/apps/adapter-inspector/index.html new file mode 100644 index 0000000..493ac79 --- /dev/null +++ b/apps/adapter-inspector/index.html @@ -0,0 +1,12 @@ + + + + + + IIOS P5 — Adapter Inspector + + +
+ + + diff --git a/apps/adapter-inspector/package.json b/apps/adapter-inspector/package.json new file mode 100644 index 0000000..e7d9ace --- /dev/null +++ b/apps/adapter-inspector/package.json @@ -0,0 +1,21 @@ +{ + "name": "adapter-inspector", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5175", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/apps/adapter-inspector/src/App.tsx b/apps/adapter-inspector/src/App.tsx new file mode 100644 index 0000000..d51a8e7 --- /dev/null +++ b/apps/adapter-inspector/src/App.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; + +const SERVICE = 'http://localhost:3200'; +const APP_ID = 'portal-demo'; + +interface RawEvent { + id: string; + channelType: string; + externalEventId?: string | null; + signatureStatus: string; + status: string; + interactionId?: string | null; +} +interface Command { + id: string; + channelType: string; + target: string; + status: string; + providerRef?: string | null; + attempts?: Array<{ id: string; status: string }>; +} + +async function devToken(userId: string): Promise { + const r = await fetch(`${SERVICE}/v1/dev/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ appId: APP_ID, userId, name: userId }), + }); + if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`); + return ((await r.json()) as { token: string }).token; +} + +async function api(token: string, path: string, method = 'GET', body?: unknown): Promise { + const r = await fetch(`${SERVICE}${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: body ? JSON.stringify(body) : undefined, + }); + return (await r.json()) as T; +} + +export function App() { + const [token, setToken] = useState(null); + const [inbound, setInbound] = useState([]); + const [outbound, setOutbound] = useState([]); + + useEffect(() => { + void devToken('inspector').then(setToken); + }, []); + + useEffect(() => { + if (!token) return; + const load = async () => { + setInbound(await api(token, '/v1/adapters/inbound')); + setOutbound(await api(token, '/v1/adapters/outbound')); + }; + void load(); + const t = setInterval(() => void load(), 2000); + return () => clearInterval(t); + }, [token]); + + if (!token) return
loading…
; + + const cell: React.CSSProperties = { padding: '4px 8px', borderBottom: '1px solid #eee' }; + + return ( +
+

IIOS P5 — Adapter Inspector

+

All simulated — no real provider is contacted.

+ + + +
+
+

Inbound raw events ({inbound.length})

+ {inbound.map((e) => ( +
+ {e.channelType} · sig:{e.signatureStatus} · {e.status} + {e.interactionId ? ` · → ${e.interactionId.slice(0, 8)}` : ''} +
+ ))} +
+
+

Outbound commands ({outbound.length})

+ {outbound.map((c) => ( +
+ {c.channelType} → {c.target} · {c.status} + {c.providerRef ? ` · ${c.providerRef}` : ''} · attempts:{c.attempts?.length ?? 0} +
+ ))} +
+
+
+ ); +} diff --git a/apps/adapter-inspector/src/main.tsx b/apps/adapter-inspector/src/main.tsx new file mode 100644 index 0000000..17a6dd8 --- /dev/null +++ b/apps/adapter-inspector/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const el = document.getElementById('root'); +if (el) createRoot(el).render(); diff --git a/apps/adapter-inspector/tsconfig.json b/apps/adapter-inspector/tsconfig.json new file mode 100644 index 0000000..1a0c48c --- /dev/null +++ b/apps/adapter-inspector/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react", "react-dom"], + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/adapter-inspector/vite.config.ts b/apps/adapter-inspector/vite.config.ts new file mode 100644 index 0000000..d79ba3f --- /dev/null +++ b/apps/adapter-inspector/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5175 }, +}); diff --git a/packages/iios-service/scripts/smoke-adapter.mjs b/packages/iios-service/scripts/smoke-adapter.mjs new file mode 100644 index 0000000..93062e2 --- /dev/null +++ b/packages/iios-service/scripts/smoke-adapter.mjs @@ -0,0 +1,67 @@ +// P5 adapter smoke: signed webhook → (async) normalized into a thread; bad +// signature rejected; sandboxed outbound recorded. Requires service running +// (relay timer on). No real network is ever contacted. +import 'dotenv/config'; +import crypto from 'node:crypto'; +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 ADAPTER_SECRET = (() => { + try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').WEBHOOK ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; } +})(); + +const sessionToken = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); +const sign = (body) => 'sha256=' + crypto.createHmac('sha256', ADAPTER_SECRET).update(body).digest('hex'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; + +async function get(path) { + const r = await fetch(`${SERVICE}${path}`, { headers: { authorization: `Bearer ${sessionToken}` } }); + if (!r.ok) throw new Error(`GET ${path} ${r.status}`); + return r.json(); +} +async function pollUntil(fn, ms = 8000) { + const end = Date.now() + ms; + while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(300); } + return null; +} + +// 1) signed webhook → 202 → async normalize +const payload = { eventId: `sm-${Date.now()}`, from: 'sim@customer', text: 'hi from a provider', threadRef: 'sm-thread' }; +const body = JSON.stringify(payload); +const wh = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/webhook`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) }, + body, +}); +assert(wh.status === 202, `signed webhook accepted (202)`); + +const normalized = await pollUntil(async () => { + const list = await get('/v1/adapters/inbound'); + const e = list.find((x) => x.externalEventId === payload.eventId); + return e && e.status === 'NORMALIZED' ? e : null; +}); +assert(normalized, 'raw event NORMALIZED (async projector)'); +assert(!!normalized.interactionId, 'ingested into a kernel interaction'); + +// 2) bad signature → rejected, nothing ingested +const bad = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/webhook`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' }, + body: JSON.stringify({ eventId: `bad-${Date.now()}`, from: 'x', text: 'x' }), +}); +assert(bad.status === 401, 'bad signature rejected (401)'); + +// 3) outbound → sandbox sink (no network) +const sendRes = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/send`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ target: 'user@x', payload: { text: 'agent reply' } }), +}); +const cmd = await sendRes.json(); +assert(cmd.status === 'SENT' && String(cmd.providerRef).includes('sim-'), 'outbound SENT via sandbox (no network)'); + +console.log('\nP5 adapter smoke: PASS'); +process.exit(0); diff --git a/packages/iios-service/src/dev/dev.controller.ts b/packages/iios-service/src/dev/dev.controller.ts index 0b89705..764a310 100644 --- a/packages/iios-service/src/dev/dev.controller.ts +++ b/packages/iios-service/src/dev/dev.controller.ts @@ -1,16 +1,23 @@ -import { BadRequestException, Body, Controller, ForbiddenException, Post } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { BadRequestException, Body, Controller, ForbiddenException, Param, Post } from '@nestjs/common'; import jwt from 'jsonwebtoken'; +import { hmacSign } from '@insignia/iios-adapter-sdk'; +import { InboundService } from '../adapters/inbound.service'; +import { AdapterRegistry } from '../adapters/adapter.registry'; /** - * Dev-only helper: mints an HS256 token a host app would normally sign, so the - * browser demo can authenticate. Gated by IIOS_DEV_TOKENS=1 — never enable in - * production (the host app signs its own tokens there). + * Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production. */ @Controller('v1/dev') export class DevController { + constructor( + private readonly inbound: InboundService, + private readonly registry: AdapterRegistry, + ) {} + @Post('token') token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } { - if (process.env.IIOS_DEV_TOKENS !== '1') throw new ForbiddenException('dev tokens disabled'); + this.assertDev(); let secrets: Record; try { secrets = JSON.parse(process.env.APP_SECRETS ?? '{}'); @@ -26,4 +33,29 @@ export class DevController { ); return { token }; } + + /** Server signs a sample provider payload and feeds it to the inbound pipeline. */ + @Post('webhook/:channelType') + async injectWebhook(@Param('channelType') channelType: string, @Body() body?: { from?: string; text?: string }) { + this.assertDev(); + const reg = this.registry.get(channelType); + if (!reg) throw new BadRequestException(`unknown channel type: ${channelType}`); + const payload = { + eventId: `dev-${randomUUID()}`, + from: body?.from ?? 'sim@customer', + displayName: 'Sim Customer', + threadRef: `sim-${randomUUID().slice(0, 8)}`, + subject: 'Simulated inbound', + text: body?.text ?? 'Hello from a simulated provider', + }; + const raw = JSON.stringify(payload); + return this.inbound.receive(channelType, raw, { + 'x-iios-signature': hmacSign(raw, reg.config.secret), + 'content-type': 'application/json', + }); + } + + private assertDev(): void { + if (process.env.IIOS_DEV_TOKENS !== '1') throw new ForbiddenException('dev endpoints disabled'); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0558772..2542fcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,31 @@ importers: specifier: ^3.0.5 version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + apps/adapter-inspector: + dependencies: + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + apps/agent-demo: dependencies: '@insignia/iios-kernel-client':