### 4NK Chat via iframe (ihm_client) and postMessage This document specifies a chat feature embedded as an iframe of `ihm_client`, communicating with the host application through `postMessage`. It maximizes reuse of existing `MessageType` where appropriate and introduces a minimal, versioned `CHAT_*` message family for chat-specific flows. ### Scope and roles - **Host app**: embeds `ihm_client` in an iframe, uses the `skeleton` `MessageBus` (or host-native bus) to exchange messages. - **ihm_client (iframe)**: renders the chat UI, persists data, validates tokens, and routes messages. - **sdk_common**: unchanged. No modification required for the chat feature. - **sdk_client**: unchanged. No modification required; chat can be driven via `skeleton` `MessageBus`. - **skeleton**: integrates the iframe and routes messages via `iframe.contentWindow.postMessage` with strict `origin`. ### Reused MessageType and rationale Use the existing `MessageType` values from `ihm_client/src/models/process.model.ts` as-is, where they semantically fit: - `LISTENING`: iframe readiness ping sent by `ihm_client` at startup. The host treats this as the chat iframe being ready. - `ERROR`: generic error channel for any failure (validation, auth, unsupported message). Include `code`, `reason`, and `refMessageId` in the payload. - `VALIDATE_TOKEN` and `RENEW_TOKEN`: reuse token validation/refresh flows for gated chat operations. - `GET_PAIRING_ID`: allows host to retrieve the user/device pairing id to correlate chat identity if needed. These are intentionally reused to avoid duplicating platform primitives. All chat-specific operations are namespaced under `CHAT_*` below. ### Message envelope (shared) - `type`: one of existing `MessageType` or a `CHAT_*` value (see below) - `messageId`: string (uuid v4), unique per message - `correlationId`: string (optional), set on replies to refer to the original `messageId` - `ts`: ISO 8601 timestamp - `origin`: implicit from `postMessage` context; the receiver must validate `event.origin` - `payload`: object, schema depends on `type` - `version`: string, e.g. `chat/1.0` Validation: implemented locally (no `sdk_common` changes). Add lightweight runtime guards in `ihm_client` and optional helpers in `sdk_client`/`skeleton`. ### Single generic message type for chat Introduce only one new `MessageType` value: `CHANNEL_MESSAGE`. All chat operations are multiplexed through `CHANNEL_MESSAGE` using an `action` field inside the payload. This minimizes new types while reusing existing primitives (`LISTENING`, `ERROR`, `VALIDATE_TOKEN`, `RENEW_TOKEN`, `GET_PAIRING_ID`). `CHANNEL_MESSAGE` payload schema: - `action`: one of - `'SUBSCRIBE' | 'UNSUBSCRIBE'` - `'SEND'` (send a message) - `'EVENT'` (deliver an event from iframe to host) - `'TYPING'` - `'READ'` - `'HISTORY_REQ' | 'HISTORY_RES'` - `'CHANNEL_LIST_REQ' | 'CHANNEL_LIST_RES'` - `channelId?`: string (required for channel-scoped actions) - `correlationId?`: string (used to pair req/res) - `data?`: object; action-specific payload: - `SUBSCRIBE/UNSUBSCRIBE`: `{}` - `SEND`: `{ content: string, contentType?: 'text/markdown'|'text/plain', attachments?: Attachment[] }` - `EVENT`: `{ event: 'MESSAGE'|'DELIVERED'|'ERROR'|'PRESENCE', payload: any }` - `TYPING`: `{ isTyping: boolean }` - `READ`: `{ messageIds: string[] }` - `HISTORY_REQ`: `{ beforeTs?: string, limit?: number }` - `HISTORY_RES`: `{ messages: ChatMessage[], nextBeforeTs?: string }` - `CHANNEL_LIST_REQ`: `{}` - `CHANNEL_LIST_RES`: `{ channels: ChatChannel[] }` Types used above: - `Attachment`: `{ id?: string, name: string, mime: string, size?: number, dataRef?: string }` - `ChatMessage`: same structure as `CHAT_MESSAGE.payload` - `ChatChannel`: `{ channelId: string, title?: string, unread?: number, lastMessageTs?: string }` ### Security and origin validation - The host must send a `CHAT_HANDSHAKE` immediately upon receiving `LISTENING` from the iframe. - The iframe must validate `event.origin` against `allowedOrigins` and respond with `CHAT_ACK` or `ERROR`. - All operations that require authentication should reuse `VALIDATE_TOKEN`/`RENEW_TOKEN` before proceeding; the iframe already has token flows. - Do not place secrets in clear within `postMessage` payloads; if encryption is needed later, wrap `content` with a ciphertext field (out of scope for v1). ### Persistence and offline (ihm_client) - Reuse the service worker + IndexedDB layer (`database.service.ts` and `service-workers/database.worker.js`). - New stores: `channels` and `messages` (indexed by `channelId`, `serverTs`, `messageId`). - Batch writes can reuse existing `BATCH_WRITING` path; single writes via `ADD_OBJECT`. - Outbox: queue unsent `CHAT_MESSAGE_SEND` for retry; deliver `CHAT_ACK` only when accepted to the outbox; emit delivery `CHAT_MESSAGE` when persisted/confirmed. ### Integration points (code) - `ihm_client/src/router.ts`: - Add handlers in `registerAllListeners()` switch for the new `CHAT_*` types (keep existing `LISTENING`, `ERROR`, token flows). - Use `window.parent.postMessage` for responses, preserving `messageId`/`correlationId`. - `skeleton/src/sdk/MessageBus.ts`: - Reuse existing `postMessage` to iframe; add a registry for pending requests keyed by `messageId` to resolve promises on `correlationId`. - Filter by strict `origin`. - `sdk_common`: - No changes. Note: `sdk_client` remains unchanged. If desired later, a thin optional wrapper can live outside `sdk_client`. ### State flows 1) Ready - iframe → host: `LISTENING` (déjà existant) - host may optionally validate tokens using `VALIDATE_TOKEN` / `RENEW_TOKEN`. 2) Send message - host → iframe: `CHANNEL_MESSAGE { action: 'SEND', channelId, data: { content, ... } }` - iframe → host: `CHANNEL_MESSAGE { action: 'EVENT', data: { event: 'DELIVERED', payload: { refMessageId } } }` - iframe → host: `CHANNEL_MESSAGE { action: 'EVENT', data: { event: 'MESSAGE', payload: ChatMessage } }` 3) Typing / Read - host ↔ iframe: `CHANNEL_MESSAGE { action: 'TYPING', channelId, data: { isTyping } }` - host → iframe: `CHANNEL_MESSAGE { action: 'READ', channelId, data: { messageIds } }` 4) History / Channels - host → iframe: `CHANNEL_MESSAGE { action: 'HISTORY_REQ', channelId, data: { beforeTs?, limit? } }` - iframe → host: `CHANNEL_MESSAGE { action: 'HISTORY_RES', channelId, data: { messages, nextBeforeTs? } }` - host → iframe: `CHANNEL_MESSAGE { action: 'CHANNEL_LIST_REQ' }` - iframe → host: `CHANNEL_MESSAGE { action: 'CHANNEL_LIST_RES', data: { channels } }` ### Error handling - Use existing `ERROR` type across all failures. Payload: - `{ code: 'UNAUTHORIZED'|'INVALID_PAYLOAD'|'UNSUPPORTED_VERSION'|'ORIGIN_FORBIDDEN'|'INTERNAL', reason: string, details?: any, refMessageId?: string }` ### Versioning - `version: 'chat/1.0'` in all `CHAT_*` envelopes. - If version mismatch, iframe must respond with `ERROR { code: 'UNSUPPORTED_VERSION' }`. ### Telemetry (optional) - Log key events: handshake_ok/fail, subscribe/unsubscribe, send/recv, history_page, ack_timeout. - Avoid logging payload content; log sizes and identifiers only. ### Minimal host-side example ```ts // skeleton host pseudo-code const bus = new MessageBus(iframe, { origin }); bus.on('message', (env) => {/* route CHAT_MESSAGE, ACK, ERROR */}); // Wait for LISTENING from iframe, then you can subscribe/send with CHANNEL_MESSAGE: bus.send({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload: { action: 'SUBSCRIBE', channelId: 'default' } }); ``` ### Minimal iframe-side example ```ts // inside ihm_client router handlers if (event.data.type === 'CHANNEL_MESSAGE' && event.data.payload?.action === 'SEND') { // validate origin + token, persist, then window.parent.postMessage({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload: { action: 'EVENT', data: { event: 'DELIVERED', payload: { refMessageId: event.data.messageId } } } }, event.origin); window.parent.postMessage({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload: { action: 'EVENT', data: { event: 'MESSAGE', payload: {/* delivered message */} } } }, event.origin); } ``` ### Functional host chat UI (pseudocode) ```ts // Host-side pseudo-UI integrating ihm_client via iframe and MessageBus import { MessageBus } from 'skeleton/src/sdk/MessageBus'; type ChatMessage = { channelId: string; messageId: string; content: string; from: string; clientTs: string; serverTs?: string }; class ChatHostApp { private iframe: HTMLIFrameElement; private bus: MessageBus; private origin: string; private currentChannelId: string = 'default'; private messages: Record = {}; constructor(container: HTMLElement, iframeSrc: string, origin: string) { this.origin = origin; this.iframe = document.createElement('iframe'); this.iframe.src = iframeSrc; this.iframe.style.width = '100%'; this.iframe.style.height = '100%'; this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin'); container.appendChild(this.iframe); this.bus = new MessageBus(this.iframe, { origin: this.origin }); this.bus.on('message', (env: any) => this.handleMessage(env)); } async init() { await this.waitForListening(); await this.subscribe(this.currentChannelId); await this.loadHistory(this.currentChannelId); } private waitForListening(): Promise { return new Promise((resolve) => { const off = this.bus.on('message', (env: any) => { if (env?.type === 'LISTENING') { off(); resolve(); } }); }); } private send(payload: any) { return this.bus.send({ type: 'CHANNEL_MESSAGE', messageId: uuid(), payload }); } async subscribe(channelId: string) { await this.send({ action: 'SUBSCRIBE', channelId }); } async sendMessage(channelId: string, content: string) { await this.send({ action: 'SEND', channelId, data: { content, contentType: 'text/plain' } }); } async setTyping(channelId: string, isTyping: boolean) { await this.send({ action: 'TYPING', channelId, data: { isTyping } }); } async markRead(channelId: string, messageIds: string[]) { await this.send({ action: 'READ', channelId, data: { messageIds } }); } async loadHistory(channelId: string, beforeTs?: string) { const correlationId = uuid(); this.bus.expect(correlationId, (env: any) => env?.type === 'CHANNEL_MESSAGE' && env?.payload?.action === 'HISTORY_RES'); this.bus.send({ type: 'CHANNEL_MESSAGE', messageId: correlationId, payload: { action: 'HISTORY_REQ', channelId, data: { beforeTs, limit: 50 } } }); } private handleMessage(env: any) { // Generic ERROR if (env?.type === 'ERROR') { console.error('[chat]', env); return; } // CHANNEL_MESSAGE events if (env?.type === 'CHANNEL_MESSAGE') { const { action, data } = env.payload || {}; if (action === 'EVENT') { switch (data?.event) { case 'MESSAGE': { const msg = data.payload as ChatMessage; const list = this.messages[msg.channelId] || (this.messages[msg.channelId] = []); list.push(msg); this.renderThread(msg.channelId); break; } case 'DELIVERED': { // Optionally update UI state for delivery break; } case 'ERROR': { console.warn('[chat-event-error]', data?.payload); break; } } } else if (action === 'HISTORY_RES') { const { channelId, messages } = data; this.messages[channelId] = messages; this.renderThread(channelId); } } } // Very simple rendering hooks (pseudo-DOM updates) renderThread(channelId: string) { const ul = document.querySelector('#messages') as HTMLUListElement; if (!ul) return; ul.innerHTML = ''; for (const m of this.messages[channelId] || []) { const li = document.createElement('li'); li.textContent = `${m.from}: ${m.content}`; ul.appendChild(li); } } } // Usage const app = new ChatHostApp(document.getElementById('chat-root')!, 'https://ihm.example.com/', 'https://ihm.example.com'); app.init(); // Bind UI controls document.getElementById('sendBtn')?.addEventListener('click', () => { const input = document.getElementById('composer') as HTMLInputElement; app.sendMessage('default', input.value); input.value = ''; }); document.getElementById('composer')?.addEventListener('input', (e) => { const typing = (e.target as HTMLInputElement).value.length > 0; app.setTyping('default', typing); }); ``` ### Delivery plan (no changes to `sdk_common` or `sdk_client`) 1) `skeleton`: enhance `MessageBus` for request/response correlation and strict origin filtering (host-side only). 2) `ihm_client`: implement `CHANNEL_MESSAGE` handlers in `registerAllListeners()` and a basic chat UI (list, thread, composer). 3) Persistence: reuse service worker/IndexedDB; add `channels`/`messages` stores and outbox logic within `ihm_client`. 4) Tests: unit (router handlers, runtime guards), integration (subscribe, send/recv, history, typing/read), E2E (Playwright, mocked backend).