diff --git a/packages/iios-adapter-sdk/package.json b/packages/iios-adapter-sdk/package.json new file mode 100644 index 0000000..ba4ff3e --- /dev/null +++ b/packages/iios-adapter-sdk/package.json @@ -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:*" + } +} diff --git a/packages/iios-adapter-sdk/src/adapter.test.ts b/packages/iios-adapter-sdk/src/adapter.test.ts new file mode 100644 index 0000000..d6d2555 --- /dev/null +++ b/packages/iios-adapter-sdk/src/adapter.test.ts @@ -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-'); + }); +}); diff --git a/packages/iios-adapter-sdk/src/fixtures.ts b/packages/iios-adapter-sdk/src/fixtures.ts new file mode 100644 index 0000000..0af7a2c --- /dev/null +++ b/packages/iios-adapter-sdk/src/fixtures.ts @@ -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 } { + const body = JSON.stringify(payload); + return { body, headers: { 'x-iios-signature': hmacSign(body, secret), 'content-type': 'application/json' } }; +} diff --git a/packages/iios-adapter-sdk/src/hmac.ts b/packages/iios-adapter-sdk/src/hmac.ts new file mode 100644 index 0000000..ae911ce --- /dev/null +++ b/packages/iios-adapter-sdk/src/hmac.ts @@ -0,0 +1,15 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** `sha256=` 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); +} diff --git a/packages/iios-adapter-sdk/src/index.ts b/packages/iios-adapter-sdk/src/index.ts new file mode 100644 index 0000000..99714d7 --- /dev/null +++ b/packages/iios-adapter-sdk/src/index.ts @@ -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'; diff --git a/packages/iios-adapter-sdk/src/sandbox.ts b/packages/iios-adapter-sdk/src/sandbox.ts new file mode 100644 index 0000000..7af52b6 --- /dev/null +++ b/packages/iios-adapter-sdk/src/sandbox.ts @@ -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 { + return { providerRef: `sim-${cmd.channelType}-${++seq}`, outcome: 'SENT', latencyMs: 1 }; + }, +}; diff --git a/packages/iios-adapter-sdk/src/types.ts b/packages/iios-adapter-sdk/src/types.ts new file mode 100644 index 0000000..e1499db --- /dev/null +++ b/packages/iios-adapter-sdk/src/types.ts @@ -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; +} + +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, secret: string): boolean; + normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest; + fetch?(ref: string): Promise; + send(cmd: OutboundCommandInput): Promise; +} diff --git a/packages/iios-adapter-sdk/src/webhook-adapter.ts b/packages/iios-adapter-sdk/src/webhook-adapter.ts new file mode 100644 index 0000000..283ac12 --- /dev/null +++ b/packages/iios-adapter-sdk/src/webhook-adapter.ts @@ -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, 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 { + return SandboxSink.simulate(cmd); + } +} diff --git a/packages/iios-adapter-sdk/tsconfig.json b/packages/iios-adapter-sdk/tsconfig.json new file mode 100644 index 0000000..554a0af --- /dev/null +++ b/packages/iios-adapter-sdk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2683c87..0a90ba9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs index 4ceeb20..5082d14 100644 --- a/scripts/check-import-boundary.mjs +++ b/scripts/check-import-boundary.mjs @@ -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': [ diff --git a/vitest.config.ts b/vitest.config.ts index 8455613..2245582 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: {