feat: P5.5 dev inject + adapter-inspector demo + adapter smoke (P5 complete)
Dev-only POST /v1/dev/webhook/:type signs+injects a sample payload. apps/adapter- inspector (Vite): inbound raw events + outbound commands/attempts, simulate-inbound + test-send buttons. smoke-adapter proves signed->normalized->interaction, bad-sig 401, sandboxed outbound (no network). 55 tests; all 4 smokes pass; boundary enforced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>IIOS P5 — Adapter Inspector</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<T>(token: string, path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||||
|
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<string | null>(null);
|
||||||
|
const [inbound, setInbound] = useState<RawEvent[]>([]);
|
||||||
|
const [outbound, setOutbound] = useState<Command[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void devToken('inspector').then(setToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
const load = async () => {
|
||||||
|
setInbound(await api<RawEvent[]>(token, '/v1/adapters/inbound'));
|
||||||
|
setOutbound(await api<Command[]>(token, '/v1/adapters/outbound'));
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const t = setInterval(() => void load(), 2000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading…</div>;
|
||||||
|
|
||||||
|
const cell: React.CSSProperties = { padding: '4px 8px', borderBottom: '1px solid #eee' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, fontFamily: 'sans-serif' }}>
|
||||||
|
<h2>IIOS P5 — Adapter Inspector</h2>
|
||||||
|
<p style={{ color: '#666' }}>All simulated — no real provider is contacted.</p>
|
||||||
|
<button onClick={() => void api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'simulated inbound' })}>
|
||||||
|
simulate inbound
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={() => void api(token, '/v1/adapters/WEBHOOK/send', 'POST', { target: 'user@x', payload: { text: 'test send' } })}
|
||||||
|
>
|
||||||
|
test send
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 24, marginTop: 16 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3>Inbound raw events ({inbound.length})</h3>
|
||||||
|
{inbound.map((e) => (
|
||||||
|
<div key={e.id} style={cell}>
|
||||||
|
<b>{e.channelType}</b> · sig:{e.signatureStatus} · {e.status}
|
||||||
|
{e.interactionId ? ` · → ${e.interactionId.slice(0, 8)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3>Outbound commands ({outbound.length})</h3>
|
||||||
|
{outbound.map((c) => (
|
||||||
|
<div key={c.id} style={cell}>
|
||||||
|
<b>{c.channelType}</b> → {c.target} · {c.status}
|
||||||
|
{c.providerRef ? ` · ${c.providerRef}` : ''} · attempts:{c.attempts?.length ?? 0}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
const el = document.getElementById('root');
|
||||||
|
if (el) createRoot(el).render(<App />);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5175 },
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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 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
|
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
||||||
* browser demo can authenticate. Gated by IIOS_DEV_TOKENS=1 — never enable in
|
|
||||||
* production (the host app signs its own tokens there).
|
|
||||||
*/
|
*/
|
||||||
@Controller('v1/dev')
|
@Controller('v1/dev')
|
||||||
export class DevController {
|
export class DevController {
|
||||||
|
constructor(
|
||||||
|
private readonly inbound: InboundService,
|
||||||
|
private readonly registry: AdapterRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
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<string, string>;
|
let secrets: Record<string, string>;
|
||||||
try {
|
try {
|
||||||
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
||||||
@@ -26,4 +33,29 @@ export class DevController {
|
|||||||
);
|
);
|
||||||
return { token };
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+25
@@ -18,6 +18,31 @@ importers:
|
|||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
|
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:
|
apps/agent-demo:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@insignia/iios-kernel-client':
|
'@insignia/iios-kernel-client':
|
||||||
|
|||||||
Reference in New Issue
Block a user