npm package: @mukkle/sdk
Description: Official SDK for integrating with Mukkle's AI content generation platform
Author: Mukkle
Latest version: 1.0.0

# @mukkle/sdk

Official **JavaScript/TypeScript SDK** for receiving webhooks from Mukkle's AI content generation platform.

> **Using a different language?** Mukkle webhooks use standard HMAC-SHA256 signatures. See [Webhook Integration Without SDK](#webhook-integration-without-sdk) for details on integrating with any language.

## Features

- 🚀 **Framework Agnostic** - Works with Next.js, Express, Cloudflare Workers, Deno, Bun, and more
- 🔐 **Secure by Default** - HMAC-SHA256 signature verification, timestamp validation
- 📦 **Modular Design** - Use only what you need via subpath imports
- 🧪 **Testing Utilities** - Mocks, fixtures, and helpers for testing your integration
- 🗄️ **Bring Your Own Storage** - No opinionated storage layer; use Prisma, Drizzle, MongoDB, or anything else
- 📝 **TypeScript First** - Full type safety with excellent IntelliSense support

## Installation

```bash
npm install @mukkle/sdk
# or
yarn add @mukkle/sdk
# or
pnpm add @mukkle/sdk
```

## Quick Start

### Basic Webhook Handler

```typescript
import { createHandler, createAdapter } from '@mukkle/sdk'

const handler = createHandler({
  // Required: Provide your credentials via functions (supports async)
  getApiKey: () => process.env.MUKKLE_API_KEY!,
  getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,

  // Required: Handle received posts
  onReceive: async (post, ctx) => {
    // Save to your database
    const saved = await db.posts.create({
      title: post.title,
      slug: post.slug,
      content: post.content,
      excerpt: post.excerpt,
      authorId: post.authorId,
      publishedAt: post.publishedAt,
      tags: post.tags,
      draft: post.draft,
    })

    // Return the URL where the post is accessible
    return {
      url: `/blog/${saved.slug}`,
      postId: saved.id,
      action: 'created',
    }
  },
})

// Create a Web Standard adapter (works with Cloudflare Workers, Next.js App Router, etc.)
export default createAdapter(handler)
```

### Next.js App Router

```typescript
// app/api/mukkle/route.ts
import { createHandler, createRouteHandlers } from '@mukkle/sdk'

const handler = createHandler({
  getApiKey: () => process.env.MUKKLE_API_KEY!,
  getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
  onReceive: async (post) => {
    await db.posts.create(post)
    return { url: `/blog/${post.slug}` }
  },
})

// Export both GET (health check) and POST (webhook) handlers
export const { GET, POST } = createRouteHandlers(handler)
```

### Express.js (Manual Integration)

```typescript
import express from 'express'
import { createHandler } from '@mukkle/sdk'

const app = express()
app.use(express.text({ type: 'application/json' }))

const handler = createHandler({
  getApiKey: () => process.env.MUKKLE_API_KEY!,
  getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
  onReceive: async (post) => {
    await db.posts.create(post)
    return { url: `/blog/${post.slug}` }
  },
})

app.all('/api/mukkle', async (req, res) => {
  const response = await handler.handle({
    method: req.method,
    headers: req.headers as Record<string, string>,
    body: req.body,
  })

  res.status(response.status)
  res.set(response.headers)
  res.json(response.body)
})
```

## Subpath Imports

The SDK is organized into focused modules:

```typescript
// Core types and utilities (zero dependencies)
import { MukklePost, MukkleError, verifySignature } from '@mukkle/sdk/core'

// Webhook receiver
import { createHandler } from '@mukkle/sdk/receiver'

// Testing utilities
import { createTestPost, simulateWebhook, createMemoryStorage } from '@mukkle/sdk/testing'

// HTTP adapters
import { createAdapter } from '@mukkle/sdk/adapters/http/generic'
```

## Configuration

### Handler Options

```typescript
const handler = createHandler({
  // Required
  getApiKey: () => string | Promise<string>,
  getSigningSecret: () => string | Promise<string>,
  onReceive: (post, ctx) => Promise<ReceiveResult>,

  // Optional hooks
  onHealthCheck: () => Promise<HealthResult>,
  onError: (error, ctx) => Promise<void>,
  validateRequest: (request) => Promise<ValidationResult>,
  transformPost: (post) => MukklePost | Promise<MukklePost>,

  // Optional configuration
  config: {
    timestampTolerance: 300,      // seconds (default: 5 minutes)
    requireSignature: true,        // default: true
    requireApiKey: true,           // default: true
    maxBodySize: 10 * 1024 * 1024, // default: 10MB
    allowedContentTypes: ['application/json'],
    allowedMethods: ['POST'],
  },
})
```

### Receive Context

Your `onReceive` handler receives context about the request:

```typescript
onReceive: async (post, ctx) => {
  console.log(ctx.requestId)   // Unique request ID
  console.log(ctx.timestamp)   // When received (Date)
  console.log(ctx.isRetry)     // Whether this is a retry
  console.log(ctx.retryCount)  // Number of previous attempts
  console.log(ctx.headers)     // All request headers

  return { url: '/blog/' + post.slug }
}
```

## Testing Your Integration

The SDK includes comprehensive testing utilities:

```typescript
import { describe, it, expect } from 'vitest'
import {
  createTestPost,
  createTestRequest,
  simulateWebhook,
  createMemoryStorage,
  createMockDependencies,
} from '@mukkle/sdk/testing'
import { createHandler } from '@mukkle/sdk'

describe('My webhook handler', () => {
  it('saves posts correctly', async () => {
    const storage = createMemoryStorage()

    const handler = createHandler({
      getApiKey: () => 'test-key',
      getSigningSecret: () => 'test-secret',
      onReceive: async (post) => {
        const saved = await storage.save(post)
        return { url: `/blog/${saved.slug}`, postId: saved.id }
      },
    })

    // simulateWebhook creates properly signed requests
    const result = await simulateWebhook(
      handler,
      createTestPost({ title: 'Test Post' }),
      { secret: 'test-secret', apiKey: 'test-key' }
    )

    expect(result.status).toBe(200)
    expect(storage.count()).toBe(1)
    expect(storage.last()?.title).toBe('Test Post')
  })

  it('rejects invalid signatures', async () => {
    const handler = createHandler({
      getApiKey: () => 'test-key',
      getSigningSecret: () => 'test-secret',
      onReceive: async (post) => ({ url: `/blog/${post.slug}` }),
    })

    const result = await simulateWebhook(
      handler,
      createTestPost(),
      { secret: 'wrong-secret', apiKey: 'test-key' } // Wrong secret!
    )

    expect(result.status).toBe(401)
  })
})
```

### Testing Utilities

| Utility | Description |
|---------|-------------|
| `createTestPost(overrides?)` | Create a valid `MukklePost` with test data |
| `createTestPosts(count, overrides?)` | Create multiple test posts |
| `createTestRequest(post, options)` | Create a signed `HandlerRequest` |
| `simulateWebhook(handler, post, options)` | Send a test webhook to your handler |
| `createMemoryStorage()` | In-memory storage for test assertions |
| `createMockDependencies(options)` | Create mock handler dependencies |

## Error Handling

The SDK uses typed errors with error codes:

```typescript
import { MukkleError, isMukkleError } from '@mukkle/sdk'

try {
  // ... handler code
} catch (error) {
  if (isMukkleError(error)) {
    console.log(error.code)       // e.g., 'SIGNATURE_INVALID'
    console.log(error.statusCode) // e.g., 401
    console.log(error.retryable)  // e.g., false
    console.log(error.message)    // Human-readable message
  }
}
```

### Error Codes

| Code | Status | Retryable | Description |
|------|--------|-----------|-------------|
| `SIGNATURE_INVALID` | 401 | No | Signature verification failed |
| `SIGNATURE_EXPIRED` | 401 | No | Timestamp outside tolerance |
| `API_KEY_INVALID` | 401 | No | API key doesn't match |
| `API_KEY_MISSING` | 401 | No | No API key provided |
| `PAYLOAD_INVALID` | 400 | No | Invalid post data |
| `PAYLOAD_TOO_LARGE` | 413 | No | Body exceeds max size |
| `CONTENT_TYPE_INVALID` | 415 | No | Wrong content type |
| `METHOD_NOT_ALLOWED` | 405 | No | Wrong HTTP method |
| `STORAGE_ERROR` | 500 | Yes | Database/storage error |
| `HANDLER_ERROR` | 500 | Yes | Your handler threw |
| `TIMEOUT` | 504 | Yes | Operation timed out |
| `RATE_LIMITED` | 429 | Yes | Too many requests |

## Security

The SDK implements several security measures:

- **HMAC-SHA256 Signatures** - Every webhook is signed
- **Timestamp Validation** - Prevents replay attacks (default: 5 minute window)
- **Constant-Time Comparison** - Prevents timing attacks
- **API Key Verification** - Additional authentication layer

### Verify Signatures Manually

```typescript
import { verifySignature, createSignature } from '@mukkle/sdk/core'

// Verify an incoming signature
const result = verifySignature({
  payload: body,
  signature: headers['x-mukkle-signature'],
  secret: signingSecret,
  timestamp: parseInt(headers['x-mukkle-timestamp']),
  tolerance: 300, // 5 minutes
})

if (!result.valid) {
  console.error(result.error.code) // 'SIGNATURE_INVALID' or 'SIGNATURE_EXPIRED'
}

// Create a signature (for testing)
const signature = createSignature(body, secret)
```

## Storage (Your Responsibility)

**Storage is intentionally not part of this SDK.** The SDK focuses on webhook verification and parsing - how you store posts is entirely up to you.

This design is intentional:

- **You know your stack** - Prisma, Drizzle, raw SQL, MongoDB, file system, whatever works for your project
- **You control the schema** - Add any fields you need beyond the core `MukklePost` type
- **You own the data** - No opinionated abstractions between you and your database

### Example: Storing Posts

```typescript
import { createHandler } from '@mukkle/sdk'
import { prisma } from './db' // Your database client

const handler = createHandler({
  getApiKey: () => process.env.MUKKLE_API_KEY!,
  getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,

  onReceive: async (post, ctx) => {
    // Use YOUR database, YOUR way
    const saved = await prisma.post.create({
      data: {
        title: post.title,
        slug: post.slug,
        content: post.content,
        excerpt: post.excerpt,
        authorId: post.authorId,
        publishedAt: new Date(post.publishedAt),
        tags: post.tags,
        draft: post.draft,
        // Add any custom fields you need
        sourceId: post.meta.sourceId,
        contentHash: post.meta.contentHash,
        receivedAt: ctx.timestamp,
      },
    })

    return {
      url: `/blog/${saved.slug}`,
      postId: saved.id,
    }
  },
})
```

### Idempotency

Use `post.meta.contentHash` to implement idempotent saves:

```typescript
onReceive: async (post) => {
  // Check if we already have this exact content
  const existing = await db.posts.findFirst({
    where: { contentHash: post.meta.contentHash }
  })

  if (existing) {
    return { url: `/blog/${existing.slug}`, postId: existing.id, action: 'skipped' }
  }

  const saved = await db.posts.create({ data: post })
  return { url: `/blog/${saved.slug}`, postId: saved.id, action: 'created' }
}
```

### Testing with In-Memory Storage

For testing, the SDK provides a simple in-memory storage utility:

```typescript
import { createMemoryStorage, createTestPost, simulateWebhook } from '@mukkle/sdk/testing'

const storage = createMemoryStorage()

const handler = createHandler({
  getApiKey: () => 'test-key',
  getSigningSecret: () => 'test-secret',
  onReceive: async (post) => {
    const saved = await storage.save(post)
    return { url: `/blog/${saved.slug}`, postId: saved.id }
  },
})

// After your test
expect(storage.count()).toBe(1)
expect(storage.last()?.title).toBe('My Post')
```

> **Note:** `createMemoryStorage()` is for testing only. In production, use your actual database.

## Webhook Integration Without SDK

If you're using Python, Go, Ruby, PHP, or another language, you can integrate directly with Mukkle webhooks:

### Webhook Headers

| Header | Description |
|--------|-------------|
| `X-Mukkle-Api-Key` | Your API key for verification |
| `X-Mukkle-Timestamp` | Unix timestamp (seconds) |
| `X-Mukkle-Signature` | HMAC-SHA256 signature |
| `X-Mukkle-Request-Id` | Unique request ID for tracing |

### Signature Verification

```python
# Python example
import hmac
import hashlib
import time

def verify_signature(body: str, signature: str, timestamp: str, secret: str, tolerance: int = 300) -> bool:
    # Check timestamp is recent (prevent replay attacks)
    now = int(time.time())
    if abs(now - int(timestamp)) > tolerance:
        return False

    # Compute expected signature
    message = f"{timestamp}.{body}"
    expected = hmac.new(
        secret.encode('utf-8'),
        message.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(expected, signature)
```

### Payload Format

```json
{
  "title": "Post Title",
  "slug": "post-title",
  "content": "# Markdown content...",
  "excerpt": "Short excerpt...",
  "author_id": "user-123",
  "author_name": "Author Name",
  "published_at": "2024-01-31T12:00:00Z",
  "target_post_id": null,
  "tags": ["tag1", "tag2"],
  "draft": false,
  "meta": {
    "description": "SEO description",
    "word_count": 500,
    "source_id": "content-123",
    "content_hash": "sha256-hash-for-deduplication"
  }
}
```

### Expected Response

Return a JSON response with:

```json
{
  "success": true,
  "data": {
    "url": "/blog/post-title",
    "postId": "your-internal-id"
  }
}
```

## Requirements

- Node.js >= 18.0.0
- TypeScript >= 5.0 (recommended)

## License

MIT