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,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 { 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<string, string>;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user