4NK_env/docs/chat-spec.md

13 KiB

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

// 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

// 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)

// 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<string, ChatMessage[]> = {};

  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<void> {
    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).