f0ea1ca553
CI / build (push) Failing after 2m8s
Adds adapter-owned externalEventId extraction to the ChannelAdapter contract (WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService, so email replays dedup on Message-ID (previously only payload.eventId worked). Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply joins the same email thread, bad signature rejected, and outbound EMAIL SENT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63 lines
2.2 KiB
TypeScript
63 lines
2.2 KiB
TypeScript
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
|
import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types';
|
|
import { hmacVerify } from './hmac';
|
|
import { SandboxSink } from './sandbox';
|
|
|
|
/** The canonical webhook payload the reference adapter understands. */
|
|
export interface WebhookPayload {
|
|
eventId: string;
|
|
from: string;
|
|
displayName?: string;
|
|
threadRef?: string;
|
|
subject?: string;
|
|
text: string;
|
|
occurredAt?: string;
|
|
}
|
|
|
|
/**
|
|
* Reference HMAC webhook adapter — proves the whole anti-corruption pipeline.
|
|
* New providers implement the same ChannelAdapter contract with their own
|
|
* signature scheme + payload mapping.
|
|
*/
|
|
export class WebhookAdapter implements ChannelAdapter {
|
|
readonly channelType: string;
|
|
readonly capability: AdapterCapability = {
|
|
canReceive: true,
|
|
canSend: true,
|
|
supportsThreads: true,
|
|
requiresFollowupFetch: false,
|
|
};
|
|
|
|
constructor(channelType = 'WEBHOOK') {
|
|
this.channelType = channelType;
|
|
}
|
|
|
|
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
|
|
const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature'];
|
|
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
|
}
|
|
|
|
externalEventId(payload: unknown): string | undefined {
|
|
const id = (payload as WebhookPayload)?.eventId;
|
|
return typeof id === 'string' ? id : undefined;
|
|
}
|
|
|
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
|
const p = payload as WebhookPayload;
|
|
return {
|
|
scope: ctx.scope,
|
|
channel: { type: this.channelType, externalChannelId: this.channelType.toLowerCase() },
|
|
source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.displayName ?? p.from },
|
|
kind: 'MESSAGE',
|
|
thread: { externalThreadId: p.threadRef ?? `wh_${p.from}`, subject: p.subject },
|
|
parts: [{ kind: 'TEXT', bodyText: p.text }],
|
|
occurredAt: p.occurredAt ?? new Date().toISOString(),
|
|
providerEventId: p.eventId,
|
|
};
|
|
}
|
|
|
|
async send(cmd: OutboundCommandInput): Promise<SendResult> {
|
|
return SandboxSink.simulate(cmd);
|
|
}
|
|
}
|