feat(sdk): P5.1 @insignia/iios-adapter-sdk — ChannelAdapter contract + HMAC webhook adapter + sandbox + fixtures

Contract (capability incl. requiresFollowupFetch seam, verifySignature, normalize,
fetch?, send), HMAC-SHA256 sign/verify (timing-safe), reference WebhookAdapter
(normalize→IngestInteractionRequest), SandboxSink (no network), email/whatsapp
fixtures. CJS build (consumed by the NestJS service). 4 tests; boundary updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:37:55 +05:30
parent 9c6e2c76e8
commit d70c7ea47b
12 changed files with 227 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@insignia/iios-adapter-sdk",
"version": "0.0.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@insignia/iios-contracts": "workspace:*"
}
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { WebhookAdapter, hmacSign, hmacVerify, signedFixture, emailFixture } from './index';
describe('iios-adapter-sdk', () => {
const secret = 'shh';
const adapter = new WebhookAdapter('WEBHOOK');
it('hmac verify: accepts a good sig, rejects tampered/absent', () => {
const body = JSON.stringify({ a: 1 });
const sig = hmacSign(body, secret);
expect(hmacVerify(body, secret, sig)).toBe(true);
expect(hmacVerify(body + 'x', secret, sig)).toBe(false);
expect(hmacVerify(body, secret, undefined)).toBe(false);
});
it('verifySignature reads x-iios-signature', () => {
const { body, headers } = signedFixture(emailFixture, secret);
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
});
it('normalize maps a provider payload into a valid ingest request', () => {
const req = adapter.normalize(emailFixture, { scope: { orgId: 'o', appId: 'a' } });
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
expect(req.source.externalId).toBe(emailFixture.from);
expect(req.parts[0]?.bodyText).toBe(emailFixture.text);
expect(req.providerEventId).toBe(emailFixture.eventId);
expect(req.thread?.externalThreadId).toBe(emailFixture.threadRef);
});
it('send goes to the sandbox (no network)', async () => {
const r = await adapter.send({ channelType: 'WEBHOOK', target: 'x', payload: { text: 'hi' } });
expect(r.outcome).toBe('SENT');
expect(r.providerRef).toContain('sim-');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { hmacSign } from './hmac';
import type { WebhookPayload } from './webhook-adapter';
/** Email-shaped provider payload (as the adapter's normalize input). */
export const emailFixture: WebhookPayload = {
eventId: 'email-1',
from: 'someone@example.com',
displayName: 'Someone',
threadRef: 'mail-thread-1',
subject: 'Question about my plot',
text: 'Hello, I have a question.',
};
/** WhatsApp-shaped provider payload. */
export const whatsappFixture: WebhookPayload = {
eventId: 'wa-1',
from: '+15551234567',
displayName: 'WA User',
threadRef: 'wa-group-1',
text: 'hi from whatsapp',
};
/** Produce a signed request (body + headers) for tests/smoke. */
export function signedFixture(payload: object, secret: string): { body: string; headers: Record<string, string> } {
const body = JSON.stringify(payload);
return { body, headers: { 'x-iios-signature': hmacSign(body, secret), 'content-type': 'application/json' } };
}
+15
View File
@@ -0,0 +1,15 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/** `sha256=<hex>` signature over the raw body (GitHub/Stripe style). */
export function hmacSign(body: string, secret: string): string {
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
}
export function hmacVerify(body: string, secret: string, signature: string | undefined): boolean {
if (!signature) return false;
const expected = hmacSign(body, secret);
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types';
export { hmacSign, hmacVerify } from './hmac';
export { SandboxSink } from './sandbox';
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
export { emailFixture, whatsappFixture, signedFixture } from './fixtures';
+13
View File
@@ -0,0 +1,13 @@
import type { OutboundCommandInput, SendResult } from './types';
let seq = 0;
/**
* Sandbox outbound sink — records a simulated send with no network I/O.
* (Real provider delivery is gated to P6+.)
*/
export const SandboxSink = {
async simulate(cmd: OutboundCommandInput): Promise<SendResult> {
return { providerRef: `sim-${cmd.channelType}-${++seq}`, outcome: 'SENT', latencyMs: 1 };
},
};
+40
View File
@@ -0,0 +1,40 @@
import type { IngestInteractionRequest, ScopeVector } from '@insignia/iios-contracts';
export interface AdapterCapability {
canReceive: boolean;
canSend: boolean;
supportsThreads?: boolean;
/** For "changed-only" providers (calendar-style) that need a follow-up fetch. */
requiresFollowupFetch?: boolean;
}
export interface NormalizeContext {
scope: ScopeVector;
}
export interface OutboundCommandInput {
channelType: string;
target: string;
payload: Record<string, unknown>;
}
export interface SendResult {
providerRef: string;
outcome: string;
latencyMs: number;
}
/**
* The anti-corruption contract every provider implements. An adapter's job ends
* at producing an IngestInteractionRequest — the kernel's IngestService does the
* rest. `fetch` is the declared-but-unimplemented seam for providers that only
* notify "something changed".
*/
export interface ChannelAdapter {
channelType: string;
capability: AdapterCapability;
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
fetch?(ref: string): Promise<unknown>;
send(cmd: OutboundCommandInput): Promise<SendResult>;
}
@@ -0,0 +1,57 @@
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);
}
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);
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+6
View File
@@ -89,6 +89,12 @@ importers:
specifier: ^6.0.7
version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
packages/iios-adapter-sdk:
dependencies:
'@insignia/iios-contracts':
specifier: workspace:*
version: link:../iios-contracts
packages/iios-contracts: {}
packages/iios-inbox-web:
+2 -1
View File
@@ -17,7 +17,8 @@ const ALLOWED = {
'@insignia/iios-contracts': [],
'@insignia/iios-testkit': ['@insignia/iios-contracts'],
'@insignia/iios-kernel-client': ['@insignia/iios-contracts'],
'@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit'],
'@insignia/iios-adapter-sdk': ['@insignia/iios-contracts'],
'@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit', '@insignia/iios-adapter-sdk'],
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
'@insignia/iios-inbox-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
'@insignia/iios-support-web': [
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
// build step. Production builds still go through tsc/dist.
'@insignia/iios-contracts': r('./packages/iios-contracts/src/index.ts'),
'@insignia/iios-testkit': r('./packages/iios-testkit/src/index.ts'),
'@insignia/iios-adapter-sdk': r('./packages/iios-adapter-sdk/src/index.ts'),
},
},
test: {