npm package: @multocms/connector
Description: Official JavaScript/TypeScript client library for MultoCMS REST API with automatic authentication and token management
Author: MultoCMS
Latest version: 1.5.1

# @multocms/connector

Official JavaScript/TypeScript client library for [MultoCMS REST API](https://multocms.com).

This package provides a type-safe, easy-to-use interface for connecting to MultoCMS APIs with automatic authentication, token management, and request/response handling.

## Features

- ✅ **Automatic Authentication** - Handles API key exchange for JWT tokens
- ✅ **Token Management** - Automatic token refresh on expiration
- ✅ **TypeScript Support** - Full type definitions for all endpoints
- ✅ **Simple API** - Clean, intuitive method names
- ✅ **Error Handling** - Consistent error responses
- ✅ **Environment Variables** - Works seamlessly with `.env` files

## Installation

```bash
npm install @multocms/connector
```

## Quick Start

### 1. Set Environment Variables

Create a `.env` file or set environment variables:

```bash
MULTOCMS_BASE_URL=https://your-site.com/api/v1
MULTOCMS_API_KEY=your-key-id.your-secret
MULTOCMS_API_SECRET=your-secret

# Optional: Cache duration in seconds (default: 0 = no cache)
MULTOCMS_CACHE_REVALIDATE=60
```

Or you can pass them directly to the constructor:

```ts
import { MultoCMSClient } from '@multocms/connector';

const client = new MultoCMSClient({
  baseUrl: 'https://your-site.com/api/v1',
  apiKey: 'your-key-id.your-secret',
  apiSecret: 'your-secret',
  cacheRevalidate: 0, // 0 = no cache (default), 60 = cache 60s, false = use Next.js default
});
```

### 2. Use the Client

```ts
import { MultoCMSClient } from '@multocms/connector';

// Client auto-authenticates with API key if provided
const client = new MultoCMSClient({
  baseUrl: process.env.MULTOCMS_BASE_URL!, // e.g., 'https://your-site.com/api/v1'
  // API key/secret from environment or passed directly
});

// Get the current site (associated with your API key)
// Client will auto-authenticate if API key is provided
const siteResponse = await client.getCurrentSite();

if (siteResponse.success && siteResponse.data) {
  const site = siteResponse.data;
  console.log('Site:', site.display_name);
  console.log('Tagline:', site.tagline);
  
  // Hero banner will be null if not set
  if (site.hero_banner) {
    console.log('Hero banner:', site.hero_banner.url);
    console.log('Sizes available:', Object.keys(site.hero_banner.sizes || {}));
  }
} else {
  console.error('Error getting site:', siteResponse.error);
}

// Get all posts
const response = await client.getPosts();
if (response.success) {
  console.log('Posts:', response.data);
} else {
  console.error('Error:', response.error);
}

// Get a single post by slug
const postResponse = await client.getPost('hello-world', {
  post_type: 'post',
  include: 'author,categories,featured_image',
});

if (postResponse.success) {
  const post = postResponse.data;
  console.log('Post title:', post.title);
  console.log('Featured image:', post.featured_image?.url);
}
```

## Authentication

The client supports two authentication methods:

### 1. API Key (Recommended for Server-to-Server)

```ts
const client = new MultoCMSClient({
  baseUrl: 'https://your-site.com/api/v1',
  apiKey: process.env.MULTOCMS_API_KEY!,
  apiSecret: process.env.MULTOCMS_API_SECRET!,
});

// Client automatically exchanges API key for JWT tokens
await client.authenticate();
```

### 2. Manual JWT Token

If you already have tokens:

```ts
const client = new MultoCMSClient({
  baseUrl: 'https://your-site.com/api/v1',
  accessToken: 'your-access-token',
  refreshToken: 'your-refresh-token',
});
```

### Token Refresh

The client automatically refreshes tokens when they expire. You can disable this:

```ts
const client = new MultoCMSClient({
  baseUrl: 'https://your-site.com/api/v1',
  apiKey: process.env.MULTOCMS_API_KEY!,
  apiSecret: process.env.MULTOCMS_API_SECRET!,
  autoRefresh: false, // Disable automatic refresh
});
```

Or manually refresh:

```ts
await client.refreshAccessToken();
```

## API Methods

### Sites

```ts
// Get all sites (includes settings for each site)
const sitesResponse = await client.getSites();
if (sitesResponse.success && sitesResponse.data) {
  sitesResponse.data.forEach(site => {
    console.log('Site:', site.display_name);
    console.log('Settings:', site.settings);
    // Each site includes all non-authentication settings
    console.log('Posts per page:', site.settings.posts_per_page);
  });
}

// Get a single site by ID (numeric or MongoDB ObjectId)
// This also includes all settings
const siteResponse = await client.getSite(2); // or client.getSite('507f1f77bcf86cd799439011');
if (siteResponse.success && siteResponse.data) {
  const site = siteResponse.data;
  console.log('Site name:', site.display_name);
  console.log('Settings:', site.settings);
  // Access specific settings
  console.log('Site title:', site.settings.site_title);
  console.log('Image sizes:', site.settings.image_sizes);
}

// Get the current site (site associated with the API key)
// This is especially useful when using API key authentication
// IMPORTANT: Make sure you've authenticated first!
await client.authenticate(); // Required if using API key

const currentSite = await client.getCurrentSite();
// Or use the shorthand:
const currentSite2 = await client.getSite('current'); // or 'me'

// Check if request was successful
if (currentSite.success && currentSite.data) {
  const site = currentSite.data;
  console.log('Site name:', site.display_name);
  console.log('Site tagline:', site.tagline);
  
  // Access hero banner information (will be null if not set)
  if (site.hero_banner) {
    const heroBanner = site.hero_banner;
    console.log('Hero banner URL:', heroBanner.url);
    console.log('Thumbnail:', heroBanner.sizes?.thumbnail?.url);
    console.log('Medium size:', heroBanner.sizes?.medium?.url);
    console.log('Large size:', heroBanner.sizes?.large?.url);
    
    // Access custom sizes (like "hero")
    if (heroBanner.sizes?.hero) {
      console.log('Hero size:', heroBanner.sizes.hero.url);
      console.log('Hero dimensions:', heroBanner.sizes.hero.width, 'x', heroBanner.sizes.hero.height);
    }
    
    // You can also access any custom size by name
    const customSize = heroBanner.sizes?.['your-custom-size-name'];
    if (customSize) {
      console.log('Custom size URL:', customSize.url);
    }
  } else {
    console.log('No hero banner set for this site');
  }

  // Access site logo (will be null if not set)
  if (site.logo) {
    console.log('Site logo URL:', site.logo.url);
    console.log('Logo alt text:', site.logo.alt_text);
  }

  // Access site favicon (will be null if not set)
  if (site.favicon) {
    console.log('Favicon URL:', site.favicon.url);
  }

  // Access site settings (all settings except authentication)
  console.log('Site settings:', site.settings);
  console.log('Site name:', site.settings.site_title);
  console.log('Posts per page:', site.settings.posts_per_page);
  console.log('Image sizes:', site.settings.image_sizes);
} else {
  console.error('Error:', currentSite.error);
}
```

### Posts

**Include Options:**
The `include` parameter accepts comma-separated values:
- `author` - Include author details
- `categories` - Include category terms
- `tags` - Include tag terms
- `featured_image` - Include featured image details
- `meta_box_fields` - Include metabox fields (custom fields from metaboxes)
- `content` - Include full post content (for list endpoints)

```ts
// List posts with filters
const posts = await client.getPosts({
  status: 'published',
  post_type: 'post',
  page: 1,
  per_page: 20,
  search: 'keyword',
  include: 'author,categories,meta_box_fields',
});

// Access metabox fields (custom fields from metaboxes)
if (posts.success && posts.data) {
  posts.data.forEach(post => {
    if (post.meta_box_fields) {
      // Access metabox fields
      console.log('Event schedule:', post.meta_box_fields.event_schedule);
      console.log('Phone:', post.meta_box_fields.phone);
      console.log('Email:', post.meta_box_fields.email);
      
      // Event schedule example (array of date/time entries)
      if (post.meta_box_fields.event_schedule) {
        post.meta_box_fields.event_schedule.forEach((entry: any) => {
          console.log(`Date: ${entry.date}, Time: ${entry.start_time} - ${entry.end_time}`);
        });
      }
      
      // Post reference example (single post ID or array of post IDs)
      if (post.meta_box_fields.related_posts) {
        // Can be a single post ID (string) or array of post IDs
        const relatedPostIds = Array.isArray(post.meta_box_fields.related_posts) 
          ? post.meta_box_fields.related_posts 
          : [post.meta_box_fields.related_posts];
        console.log('Related post IDs:', relatedPostIds);
      }
    }
  });
}

// Get posts from this month (published this month)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);

const thisMonthsPosts = await client.getPosts({
  status: 'published',
  date_from: startOfMonth.toISOString(),
  date_to: endOfMonth.toISOString(),
  date_field: 'published_at', // Optional, defaults to 'published_at'
});

// Get posts published in a specific date range
const dateRangePosts = await client.getPosts({
  status: 'published',
  date_from: '2025-01-01T00:00:00Z',
  date_to: '2025-01-31T23:59:59Z',
  date_field: 'published_at',
});

// Filter by creation date instead
const createdThisMonth = await client.getPosts({
  date_from: startOfMonth.toISOString(),
  date_to: endOfMonth.toISOString(),
  date_field: 'created_at',
});

// Filter posts by term (taxonomy term)
// Get posts with a specific term by ID
const postsByTerm = await client.getPosts({
  term_id: '507f1f77bcf86cd799439011',
  status: 'published',
});

// Get posts with a specific term by slug
const postsByTermSlug = await client.getPosts({
  term_slug: 'technology',
  status: 'published',
});

// Get posts with multiple terms (posts that have ANY of the terms)
const postsByMultipleTerms = await client.getPosts({
  term_ids: '507f1f77bcf86cd799439011,507f1f77bcf86cd799439012',
  status: 'published',
});

// Filter by term and restrict to a specific taxonomy
const categoryPosts = await client.getPosts({
  term_slug: 'technology',
  taxonomy: 'category',
  status: 'published',
});

// Featured image is always included if present (no need to request it)
if (posts.success && posts.data) {
  posts.data.forEach(post => {
    if (post.featured_image) {
      console.log('Featured image URL:', post.featured_image.url);
      console.log('Alt text:', post.featured_image.alt_text);
      console.log('MIME type:', post.featured_image.mime_type);
      
      // Access different sizes
      if (post.featured_image.sizes) {
        console.log('Thumbnail:', post.featured_image.sizes.thumbnail?.url);
        console.log('Medium:', post.featured_image.sizes.medium?.url);
        console.log('Large:', post.featured_image.sizes.large?.url);
        
        // Access custom sizes
        const customSize = post.featured_image.sizes['your-custom-size'];
        if (customSize) {
          console.log('Custom size:', customSize.url);
        }
      }
    }
  });
}

// Get single post by ID or slug
const post = await client.getPost('hello-world', {
  post_type: 'post',
  include: 'author,categories,tags,meta_box_fields',
});

// Access metabox fields
if (post.success && post.data?.meta_box_fields) {
  const metaFields = post.data.meta_box_fields;
  console.log('Event schedule:', metaFields.event_schedule);
  console.log('Phone:', metaFields.phone);
  
  // Post reference fields (can be single ID or array of IDs)
  if (metaFields.related_posts) {
    const relatedPostIds = Array.isArray(metaFields.related_posts) 
      ? metaFields.related_posts 
      : [metaFields.related_posts];
    console.log('Related post IDs:', relatedPostIds);
  }
  
  // ... other metabox fields
}

// Featured image is always included
if (post.success && post.data?.featured_image) {
  const featuredImage = post.data.featured_image;
  console.log('Featured image:', featuredImage.url);
  console.log('Dimensions:', featuredImage.width, 'x', featuredImage.height);
}

// Create a post
const newPost = await client.createPost({
  title: 'My New Post',
  content: '<p>Post content</p>',
  excerpt: 'Post excerpt',
  status: 'published',
  post_type: 'post',
});

// Update a post (full)
await client.updatePost(postId, {
  title: 'Updated Title',
  content: '<p>Updated content</p>',
});

// Partially update a post
await client.patchPost(postId, {
  status: 'published',
});

// Delete a post
await client.deletePost(postId);

// Get post meta (custom fields)
const metaResponse = await client.getPostMeta(postId);
if (metaResponse.success) {
  metaResponse.data?.forEach(meta => {
    console.log(`${meta.meta_key}: ${meta.meta_value}`);
  });
}

// Update post meta (batch update with object)
await client.updatePostMeta(postId, {
  '_mb_phone': '(123) 456-7890',
  '_mb_email': 'contact@example.com',
});

// Update post meta (batch update with array)
await client.updatePostMeta(postId, [
  { meta_key: '_mb_phone', meta_value: '(123) 456-7890' },
  { meta_key: '_mb_email', meta_value: 'contact@example.com' },
]);

// Update a single post meta value
await client.updatePostMetaValue(postId, '_mb_phone', '(123) 456-7890');
```

### Media

```ts
// List media files
const media = await client.getMedia({
  page: 1,
  per_page: 20,
  search: 'image',
  mime_type: 'image/jpeg',
});

// Get single media file
const mediaItem = await client.getMediaItem(mediaId);

// Upload a media file
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = fileInput.files?.[0];

if (file) {
  const uploadResponse = await client.uploadMedia(file, {
    alt_text: 'A beautiful image',
    caption: 'Image caption',
    folder_id: '507f1f77bcf86cd799439011', // Optional: upload to specific folder
  });

  if (uploadResponse.success && uploadResponse.data) {
    console.log('Uploaded media ID:', uploadResponse.data.id);
    console.log('File URL:', uploadResponse.data.filepath);
    console.log('Image sizes:', uploadResponse.data.sizes);
  } else {
    console.error('Upload failed:', uploadResponse.error);
  }
}

// Upload with File object (Node.js)
import fs from 'fs';
const fileBuffer = fs.readFileSync('./image.jpg');
const blob = new Blob([fileBuffer], { type: 'image/jpeg' });
const file = new File([blob], 'image.jpg', { type: 'image/jpeg' });

const uploadResponse = await client.uploadMedia(file, {
  alt_text: 'My image',
});

// Upload to a specific folder by name
const folderResponse = await client.getFolderByName('Images');
if (folderResponse.success && folderResponse.data) {
  const uploadResponse = await client.uploadMedia(file, {
    alt_text: 'My image',
    folder_id: folderResponse.data.id,
  });
}
```

### Media Folders

```ts
// List all folders
const folders = await client.getFolders();

// List folders in a specific parent folder
const subfolders = await client.getFolders({
  parent_id: '507f1f77bcf86cd799439011',
});

// Search folders by name
const searchResults = await client.getFolders({
  search: 'images',
});

// Get folder by exact name (convenience method)
const folderResponse = await client.getFolderByName('Images');
if (folderResponse.success && folderResponse.data) {
  console.log('Folder ID:', folderResponse.data.id);
  console.log('File count:', folderResponse.data.file_count);
  console.log('Subfolder count:', folderResponse.data.subfolder_count);
}

// Get folder by name in a specific parent
const subfolderResponse = await client.getFolderByName('2025', '507f1f77bcf86cd799439011');
```

### Menus

```ts
// List all menus
const menus = await client.getMenus();

// Get menu by ID
const menu = await client.getMenu(menuId);

// Get menu by location slug
const mainMenu = await client.getMenuByLocation('primary');
```

### Taxonomies & Terms

```ts
// List all taxonomies
const taxonomies = await client.getTaxonomies();

// Get single taxonomy
const categoryTax = await client.getTaxonomy('category');

// List terms for a taxonomy
const categories = await client.getTerms('category', {
  page: 1,
  per_page: 50,
});

// Get single term
const category = await client.getTerm('category', termId);

// Create a term
const newCategory = await client.createTerm('category', {
  name: 'News',
  slug: 'news',
  description: 'News category',
});

// Update a term
await client.updateTerm('category', termId, {
  name: 'Updated Category',
});

// Delete a term
await client.deleteTerm('category', termId);
```

### Users

```ts
// List users (requires manage_users permission)
const users = await client.getUsers({
  page: 1,
  per_page: 20,
  search: 'john',
});

// Get current authenticated user
const currentUser = await client.getCurrentUser();

// Get single user
const user = await client.getUser(userId);

// Create user
const newUser = await client.createUser({
  username: 'johndoe',
  email: 'john@example.com',
  password: 'secure-password',
  first_name: 'John',
  last_name: 'Doe',
});

// Update user
await client.updateUser(userId, {
  first_name: 'Jane',
});

// Delete user
await client.deleteUser(userId);
```

### Settings

```ts
// Get all settings
const settings = await client.getSettings();

// Get single setting
const siteName = await client.getSetting('site_name');

// Update a setting
await client.updateSetting('site_name', 'My Site', {
  type: 'string',
  description: 'Site name',
});
```

## Response Format

All methods return a consistent response format:

```ts
interface ApiResponse<T> {
  success: boolean;
  data?: T;              // Present when success === true
  error?: {              // Present when success === false
    code: string;
    message: string;
    details?: any;
  };
  meta?: {
    timestamp: string;
    version: string;
    pagination?: {
      total: number;
      count: number;
      per_page: number;
      current_page: number;
      total_pages: number;
    };
  };
  links?: {
    self?: string;
    next?: string;
    prev?: string;
    first?: string;
    last?: string;
  };
}
```

## Error Handling

```ts
const response = await client.getPost('invalid-post');

if (!response.success) {
  console.error('Error Code:', response.error?.code);
  console.error('Error Message:', response.error?.message);
  
  switch (response.error?.code) {
    case 'NOT_FOUND':
      // Handle 404
      break;
    case 'UNAUTHORIZED':
      // Handle 401 - might need to re-authenticate
      await client.authenticate();
      break;
    case 'FORBIDDEN':
      // Handle 403 - insufficient permissions
      break;
    default:
      // Handle other errors
  }
}
```

## TypeScript Support

Full TypeScript types are included:

```ts
import { MultoCMSClient, Post, Site, Media, User } from '@multocms/connector';

const client = new MultoCMSClient({
  baseUrl: process.env.MULTOCMS_BASE_URL!,
});

const response = await client.getPost('hello-world');
if (response.success) {
  // response.data is typed as Post
  const post: Post = response.data;
  
  // TypeScript knows about all Post properties
  console.log(post.title);      // ✅ string
  console.log(post.slug);        // ✅ string
  console.log(post.featured_image?.url); // ✅ string | undefined
}
```

## Utility Methods

```ts
// Manually set tokens
client.setAccessToken('token');
client.setRefreshToken('refresh-token');

// Get current access token
const token = client.getAccessToken();

// Clear tokens (logout)
client.logout();
```

## Environment Variables

The client automatically reads these environment variables:

- `MULTOCMS_BASE_URL` - Base URL for the API (required)
- `MULTOCMS_API_KEY` - API key ID.secret format
- `MULTOCMS_API_SECRET` - API secret (if not in key format)
- `MULTOCMS_ACCESS_TOKEN` - Pre-existing access token
- `MULTOCMS_REFRESH_TOKEN` - Pre-existing refresh token

## Examples

### Next.js API Route

```ts
// app/api/posts/route.ts
import { MultoCMSClient } from '@multocms/connector';

export async function GET() {
  const client = new MultoCMSClient({
    baseUrl: process.env.MULTOCMS_BASE_URL!,
  });

  const response = await client.getPosts({
    status: 'published',
    per_page: 10,
  });

  if (response.success) {
    return Response.json(response.data);
  }

  return Response.json(
    { error: response.error?.message },
    { status: 400 }
  );
}
```

### React Server Component

```tsx
// app/posts/page.tsx
import { MultoCMSClient } from '@multocms/connector';

export default async function PostsPage() {
  const client = new MultoCMSClient({
    baseUrl: process.env.MULTOCMS_BASE_URL!,
  });

  const response = await client.getPosts({
    status: 'published',
    per_page: 10,
  });

  if (!response.success) {
    return <div>Error: {response.error?.message}</div>;
  }

  return (
    <div>
      <h1>Posts</h1>
      {response.data?.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <div dangerouslySetInnerHTML={{ __html: post.excerpt }} />
        </article>
      ))}
    </div>
  );
}
```

### Node.js Script

```ts
// scripts/sync-posts.ts
import { MultoCMSClient } from '@multocms/connector';
import dotenv from 'dotenv';

dotenv.config();

async function syncPosts() {
  const client = new MultoCMSClient({
    baseUrl: process.env.MULTOCMS_BASE_URL!,
  });

  const response = await client.getPosts({
    status: 'published',
    per_page: 100,
  });

  if (response.success) {
    console.log(`Found ${response.data?.length} posts`);
    response.data?.forEach((post) => {
      console.log(`- ${post.title} (${post.slug})`);
    });
  }
}

syncPosts();
```

## Changelog

### v1.3.7 (Latest)
- **NEW**: Configurable cache revalidation via `cacheRevalidate` option or `MULTOCMS_CACHE_REVALIDATE` env var
- **FIX**: Fixed authentication retry logic to properly rebuild fetch options with updated headers
- Set to `0` for no caching (default), `60` for 60 seconds, etc.
- Set to `false` to use Next.js default caching behavior

## License

MIT

## Support

For issues, questions, or contributions, please visit the [MultoCMS Web Site](https://multocms.com).