feat(p6): route-admin demo + route smoke; P6 verification green

Task 6.6: apps/route-admin (Vite, port 5176) — create bindings, simulate a
message (preview-first, no send), see color-coded per-destination decisions
with reason codes + preview, approve/deny REVIEW rows; built on the
community-web SDK via CommunityProvider.

scripts/smoke-route.mjs: 3 MANUAL bindings → 'party at 9 PM' → children(CHILD)
DENY / adults+seniors REVIEW / 0 sends on simulate; approve → exactly one
sandbox forward; AUTOMATIC safe binding auto-forwards via RouteProjector.

P6 verification: 63 tests green, pnpm -r build all packages+demos, boundary
passes and fails on community-web→service, route smoke PASS, prior smokes
(realtime/inbox/support/adapter) PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:33:42 +05:30
parent 1ee737c025
commit 54c47dd133
8 changed files with 405 additions and 0 deletions
+197
View File
@@ -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<string, string> = {
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<string> {
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<RouteDecision[]>([]);
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<typeof createBinding>[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 (
<div style={{ padding: 24, fontFamily: 'sans-serif', maxWidth: 900, margin: '0 auto' }}>
<h2>IIOS P6 Route Admin</h2>
<p style={{ color: '#666' }}>Preview-first. Simulation never sends. Restricted destinations are deny-by-default.</p>
<div style={box}>
<h3>1 · Create binding</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<label>origin <input value={origin} onChange={(e) => setOrigin(e.target.value)} style={{ width: 90 }} /></label>
<span></span>
<label>dest <input value={dest} onChange={(e) => setDest(e.target.value)} style={{ width: 90 }} /></label>
<label>destRef <input value={destRef} onChange={(e) => setDestRef(e.target.value)} style={{ width: 130 }} /></label>
<label>
restriction{' '}
<select value={restriction} onChange={(e) => setRestriction(e.target.value)}>
<option value="">(none)</option>
<option value="CHILD">CHILD</option>
<option value="ADULT">ADULT</option>
</select>
</label>
<label>
mode{' '}
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option>MANUAL</option>
<option>AUTOMATIC</option>
<option>HYBRID</option>
<option>SIMULATION_ONLY</option>
</select>
</label>
<button onClick={() => void onCreate()}>+ Add</button>
</div>
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
<thead>
<tr style={{ textAlign: 'left', color: '#666' }}>
<th style={cell}>origin</th><th style={cell}>destination</th><th style={cell}>restriction</th><th style={cell}>mode</th><th style={cell}>enabled</th>
</tr>
</thead>
<tbody>
{bindings.map((b) => (
<tr key={b.id}>
<td style={cell}>{b.originChannelType}{b.originRef ? `:${b.originRef}` : ''}</td>
<td style={cell}>{b.destinationChannelType}{b.destinationRef ? `:${b.destinationRef}` : ''}</td>
<td style={cell}>{b.restrictionProfile ?? '—'}</td>
<td style={cell}>{b.mode}</td>
<td style={cell}>{b.enabled ? '✓' : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={box}>
<h3>2 · Simulate a message (no send)</h3>
<div style={{ display: 'flex', gap: 8 }}>
<input value={text} onChange={(e) => setText(e.target.value)} style={{ flex: 1 }} />
<button onClick={() => void onSimulate()}>Simulate on {origin}</button>
</div>
{busy && <p style={{ color: '#666' }}>{busy}</p>}
{simRows.length > 0 && (
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
<thead>
<tr style={{ textAlign: 'left', color: '#666' }}>
<th style={cell}>destination</th><th style={cell}>decision</th><th style={cell}>reason codes</th><th style={cell}>preview</th>
</tr>
</thead>
<tbody>{simRows.map((d) => <DecisionRow key={d.id} d={d} />)}</tbody>
</table>
)}
</div>
<div style={box}>
<h3>3 · Pending decisions (approve / deny)</h3>
{decisions.length === 0 && <p style={{ color: '#666' }}>No decisions yet simulate above.</p>}
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{decisions.map((d) => (
<tr key={d.id}>
<DecisionCells d={d} />
<td style={cell}>
{d.decisionState === 'REVIEW' ? (
<>
<button onClick={() => void approve(d.id)} style={{ marginRight: 6 }}>Approve</button>
<button onClick={() => void deny(d.id)}>Deny</button>
</>
) : d.executed ? '↪ forwarded (sandbox)' : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
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 (
<tr>
<td style={cell}>{d.routeBinding ? `${d.routeBinding.destinationChannelType}${d.routeBinding.destinationRef ? `:${d.routeBinding.destinationRef}` : ''}` : (p.destinationRef ?? '—')}</td>
<td style={{ ...cell, color: STATE_COLORS[d.decisionState] ?? '#000', fontWeight: 600 }}>{d.decisionState}</td>
<td style={cell}>{d.reasonCodes.join(', ') || '—'}</td>
<td style={{ ...cell, color: '#555' }}>{p.text ?? '—'}</td>
</tr>
);
}
function DecisionCells({ d }: { d: RouteDecision }) {
const p = payloadOf(d);
return (
<>
<td style={cell}>{d.routeBinding ? `${d.routeBinding.destinationChannelType}${d.routeBinding.destinationRef ? `:${d.routeBinding.destinationRef}` : ''}` : '—'}</td>
<td style={{ ...cell, color: STATE_COLORS[d.decisionState] ?? '#000', fontWeight: 600 }}>{d.decisionState}</td>
<td style={cell}>{d.reasonCodes.join(', ') || '—'}</td>
<td style={{ ...cell, color: '#555' }}>{p.text ?? '—'}</td>
</>
);
}
+32
View File
@@ -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<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;
}
function Root() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
void devToken('route-admin').then(setToken);
}, []);
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading</div>;
return (
<CommunityProvider serviceUrl={SERVICE} token={token}>
<App token={token} />
</CommunityProvider>
);
}
const el = document.getElementById('root');
if (el) createRoot(el).render(<Root />);