4NK_env/docs/chat-spec.md

277 lines
13 KiB
Markdown

### 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<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).