diff --git a/apps/route-admin/index.html b/apps/route-admin/index.html new file mode 100644 index 0000000..50fe3fd --- /dev/null +++ b/apps/route-admin/index.html @@ -0,0 +1,12 @@ + + + + + + IIOS P6 — Route Admin + + +
+ + + diff --git a/apps/route-admin/package.json b/apps/route-admin/package.json new file mode 100644 index 0000000..6f94fc8 --- /dev/null +++ b/apps/route-admin/package.json @@ -0,0 +1,23 @@ +{ + "name": "route-admin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5176", + "build": "vite build" + }, + "dependencies": { + "@insignia/iios-community-web": "workspace:*", + "@insignia/iios-kernel-client": "workspace:*", + "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/route-admin/src/App.tsx b/apps/route-admin/src/App.tsx new file mode 100644 index 0000000..7ec6e91 --- /dev/null +++ b/apps/route-admin/src/App.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import { useBindings, useRoutePreview, useRouteDecisions } from '@insignia/iios-community-web'; +import type { RouteDecision } from '@insignia/iios-kernel-client'; + +export const SERVICE = 'http://localhost:3200'; + +const STATE_COLORS: Record = { + ALLOW: '#16a34a', + DENY: '#dc2626', + REVIEW: '#d97706', + SUPPRESS: '#6b7280', + SIMULATED: '#2563eb', +}; + +/** Inject a signed dev webhook and poll the raw-event log until it normalizes into an interaction. */ +async function ingestTestMessage(token: string, text: string): Promise { + const inject = await fetch(`${SERVICE}/v1/dev/webhook/WEBHOOK`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!inject.ok) throw new Error(`inject ${inject.status}`); + const { rawEventId } = (await inject.json()) as { rawEventId: string }; + for (let i = 0; i < 40; i++) { + const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } }); + const rows = (await r.json()) as Array<{ id: string; interactionId?: string | null }>; + const hit = rows.find((x) => x.id === rawEventId); + if (hit?.interactionId) return hit.interactionId; + await new Promise((res) => setTimeout(res, 150)); + } + throw new Error('interaction never normalized'); +} + +const cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee', verticalAlign: 'top' }; +const box: React.CSSProperties = { border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 }; + +export function App({ token }: { token: string }) { + const { bindings, createBinding } = useBindings(); + const preview = useRoutePreview(); + const { decisions, approve, deny } = useRouteDecisions({ pollMs: 2000 }); + + const [origin, setOrigin] = useState('WEBHOOK'); + const [dest, setDest] = useState('PORTAL'); + const [destRef, setDestRef] = useState('parents-group'); + const [restriction, setRestriction] = useState(''); + const [mode, setMode] = useState('MANUAL'); + const [text, setText] = useState('There is a party at 9 PM tonight!'); + const [simRows, setSimRows] = useState([]); + const [busy, setBusy] = useState(''); + + const onCreate = async () => { + await createBinding({ + originChannelType: origin, + destinationChannelType: dest, + destinationRef: destRef || undefined, + restrictionProfile: restriction || undefined, + mode, + requiresReview: true, + enabled: true, + } as Parameters[0]); + }; + + const onSimulate = async () => { + setBusy('ingesting…'); + try { + const interactionId = await ingestTestMessage(token, text); + setBusy('simulating…'); + setSimRows(await preview(interactionId, origin)); + } catch (e) { + setBusy(`error: ${(e as Error).message}`); + return; + } + setBusy(''); + }; + + return ( +
+

IIOS P6 — Route Admin

+

Preview-first. Simulation never sends. Restricted destinations are deny-by-default.

+ +
+

1 · Create binding

+
+ + + + + + + +
+ + + + + + + + {bindings.map((b) => ( + + + + + + + + ))} + +
origindestinationrestrictionmodeenabled
{b.originChannelType}{b.originRef ? `:${b.originRef}` : ''}{b.destinationChannelType}{b.destinationRef ? `:${b.destinationRef}` : ''}{b.restrictionProfile ?? '—'}{b.mode}{b.enabled ? '✓' : '—'}
+
+ +
+

2 · Simulate a message (no send)

+
+ setText(e.target.value)} style={{ flex: 1 }} /> + +
+ {busy &&

{busy}

} + {simRows.length > 0 && ( + + + + + + + {simRows.map((d) => )} +
destinationdecisionreason codespreview
+ )} +
+ +
+

3 · Pending decisions (approve / deny)

+ {decisions.length === 0 &&

No decisions yet — simulate above.

} + + + {decisions.map((d) => ( + + + + + ))} + +
+ {d.decisionState === 'REVIEW' ? ( + <> + + + + ) : d.executed ? '↪ forwarded (sandbox)' : '—'} +
+
+
+ ); +} + +function payloadOf(d: RouteDecision): { text?: string; destinationRef?: string | null } { + return (d.previewPayload ?? {}) as { text?: string; destinationRef?: string | null }; +} + +function DecisionRow({ d }: { d: RouteDecision }) { + const p = payloadOf(d); + return ( + + {d.routeBinding ? `${d.routeBinding.destinationChannelType}${d.routeBinding.destinationRef ? `:${d.routeBinding.destinationRef}` : ''}` : (p.destinationRef ?? '—')} + {d.decisionState} + {d.reasonCodes.join(', ') || '—'} + {p.text ?? '—'} + + ); +} + +function DecisionCells({ d }: { d: RouteDecision }) { + const p = payloadOf(d); + return ( + <> + {d.routeBinding ? `${d.routeBinding.destinationChannelType}${d.routeBinding.destinationRef ? `:${d.routeBinding.destinationRef}` : ''}` : '—'} + {d.decisionState} + {d.reasonCodes.join(', ') || '—'} + {p.text ?? '—'} + + ); +} diff --git a/apps/route-admin/src/main.tsx b/apps/route-admin/src/main.tsx new file mode 100644 index 0000000..9b44654 --- /dev/null +++ b/apps/route-admin/src/main.tsx @@ -0,0 +1,32 @@ +import { createRoot } from 'react-dom/client'; +import { CommunityProvider } from '@insignia/iios-community-web'; +import { App, SERVICE } from './App'; +import { useEffect, useState } from 'react'; + +const APP_ID = 'portal-demo'; + +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; +} + +function Root() { + const [token, setToken] = useState(null); + useEffect(() => { + void devToken('route-admin').then(setToken); + }, []); + if (!token) return
loading…
; + return ( + + + + ); +} + +const el = document.getElementById('root'); +if (el) createRoot(el).render(); diff --git a/apps/route-admin/tsconfig.json b/apps/route-admin/tsconfig.json new file mode 100644 index 0000000..1a0c48c --- /dev/null +++ b/apps/route-admin/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/route-admin/vite.config.ts b/apps/route-admin/vite.config.ts new file mode 100644 index 0000000..4292e68 --- /dev/null +++ b/apps/route-admin/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5176 }, +}); diff --git a/packages/iios-service/scripts/smoke-route.mjs b/packages/iios-service/scripts/smoke-route.mjs new file mode 100644 index 0000000..0e3599d --- /dev/null +++ b/packages/iios-service/scripts/smoke-route.mjs @@ -0,0 +1,91 @@ +// P6 route smoke: preview-first, deny-by-default, approve→sandbox, AUTOMATIC auto-forward. +// Requires service running (relay timer on) with IIOS_DEV_TOKENS not needed here — +// we mint our own session token + sign webhooks directly. No real network is 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 token = jwt.sign({ sub: 'route-admin', 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 req(path, method = 'GET', body) { + const r = await fetch(`${SERVICE}${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`); + 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; +} +const outboundCount = async () => (await req('/v1/adapters/outbound')).length; + +async function ingest(text) { + const payload = { eventId: `route-sm-${crypto.randomUUID()}`, from: 'sim@parent', text, threadRef: `sm-${Date.now()}` }; + 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, + }); + if (wh.status !== 202) throw new Error(`webhook ${wh.status}`); + const e = await pollUntil(async () => { + const list = await req('/v1/adapters/inbound'); + const hit = list.find((x) => x.externalEventId === payload.eventId); + return hit && hit.interactionId ? hit : null; + }); + if (!e) throw new Error('interaction never normalized'); + return e.interactionId; +} + +// ── Phase A: three MANUAL bindings, preview-first ────────────────────────── +const mk = (destinationRef, restrictionProfile) => req('/v1/routes/bindings', 'POST', { + originChannelType: 'WEBHOOK', destinationChannelType: 'PORTAL', destinationRef, + restrictionProfile, mode: 'MANUAL', requiresReview: true, enabled: true, +}); +const adult = await mk('adults', undefined); +const seniors = await mk('seniors', undefined); +const children = await mk('children', 'CHILD'); +assert(adult.id && seniors.id && children.id, 'created 3 bindings (adults / seniors / children[CHILD])'); + +const base = await outboundCount(); +const interactionId = await ingest('There is a party at 9 PM tonight!'); +const { decisions } = await req('/v1/routes/simulate', 'POST', { interactionId, originChannelType: 'WEBHOOK' }); +const byBinding = Object.fromEntries(decisions.map((d) => [d.routeBindingId, d])); +assert(byBinding[children.id]?.decisionState === 'DENY', 'children (restricted) → DENY (deny-by-default)'); +assert(byBinding[adult.id]?.decisionState === 'REVIEW', 'adults → REVIEW'); +assert(byBinding[seniors.id]?.decisionState === 'REVIEW', 'seniors → REVIEW'); +assert((await outboundCount()) === base, 'simulate sent nothing (0 new outbound commands)'); + +// ── Phase B: approve a REVIEW decision → forwards to sandbox ──────────────── +const approved = await req(`/v1/routes/decisions/${byBinding[seniors.id].id}/approve`, 'POST'); +assert(approved.decisionState === 'ALLOW' && approved.executed, 'approved seniors → ALLOW + executed'); +assert((await outboundCount()) === base + 1, 'approval produced exactly one sandbox forward'); + +// ── Phase C: AUTOMATIC safe binding → auto-forward on ingest ──────────────── +// originRef 'webhook' = the WEBHOOK adapter's channel id, so the RouteProjector +// (which matches on the interaction's channel ref) picks this binding up. +await req('/v1/routes/bindings', 'POST', { + originChannelType: 'WEBHOOK', originRef: 'webhook', destinationChannelType: 'PORTAL', destinationRef: 'staff', + mode: 'AUTOMATIC', requiresReview: false, enabled: true, +}); +const base2 = await outboundCount(); +await ingest('Team standup notes are posted for everyone.'); +const grew = await pollUntil(async () => (await outboundCount()) > base2, 8000); +assert(grew, 'AUTOMATIC safe binding auto-forwarded (sandbox) via RouteProjector'); + +console.log('\nP6 route smoke: PASS'); +process.exit(0); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fda06b6..94a6e06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,37 @@ importers: specifier: ^6.0.7 version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + apps/route-admin: + dependencies: + '@insignia/iios-community-web': + specifier: workspace:* + version: link:../../packages/iios-community-web + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../../packages/iios-kernel-client + 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) + packages/iios-adapter-sdk: dependencies: '@insignia/iios-contracts':