@theloremi/cms-client
v0.9.3
Published
Typed multi-tenant headless CMS SDK client and API built on Cloudflare Workers
Readme
@theloremi/cms-client — v0.9.3
Typed TypeScript client SDK for the Loremi CMS API.
Install
npm install @theloremi/cms-clientQuick Start
Interactive (browser) — Supabase session auth
import { CmsClient } from '@theloremi/cms-client'
const cms = new CmsClient({
baseUrl: 'https://your-loremi-cms.workers.dev',
tenant: 'my-org',
supabaseUrl: 'https://xxxx.supabase.co',
supabaseAnonKey: 'your-supabase-anon-key',
})
// Authenticate
await cms.auth.signIn('[email protected]', 'password123')
// Create content
const entry = await cms.content.create('posts', {
title: 'Hello World',
body: 'My first post',
})
// Publish
await cms.content.publish('posts', entry.id)Server-side — API token auth
For server-side rendering, CI pipelines, or agents that don't have a user session, use an API token:
import { CmsClient } from '@theloremi/cms-client'
const cms = new CmsClient({
baseUrl: 'https://your-loremi-cms.workers.dev',
tenant: 'my-org',
supabaseUrl: 'https://xxxx.supabase.co',
supabaseAnonKey: 'your-supabase-anon-key',
apiToken: process.env.CMS_AUTH_TOKEN, // lrm_... token
})
// No sign-in needed — token is used as Bearer auth automatically
const { entries } = await cms.content.list('posts', { status: 'published' })API tokens are created via the CMS API by an admin/owner user. They carry a role (viewer, editor, admin, owner) and go through the same RBAC permission system as user sessions. See the API Tokens section below.
Custom auth provider (Firebase, Auth0, etc.)
For CMS deployments using non-Supabase auth, provide a custom token function:
import { CmsClient } from '@theloremi/cms-client'
import { getAuth } from 'firebase/auth'
const cms = new CmsClient({
baseUrl: 'https://your-loremi-cms.example.com',
tenant: 'my-org',
getToken: async () => {
const user = getAuth().currentUser
return user ? await user.getIdToken() : null
},
})
// All API calls use the Firebase ID token automatically
const { entries } = await cms.content.list('posts')API token only (no interactive auth)
For server-side consumers that only need API token access:
import { CmsClient } from '@theloremi/cms-client'
const cms = new CmsClient({
baseUrl: 'https://your-loremi-cms.example.com',
tenant: 'my-org',
apiToken: process.env.CMS_AUTH_TOKEN,
})
// No supabaseUrl/supabaseAnonKey needed
const { entries } = await cms.content.list('posts')Sub-Clients
The CmsClient composes 9 sub-clients, each accessible as a property:
| Client | Property | Description |
|--------|----------|-------------|
| AuthClient | cms.auth | Sign in/up, session management |
| ContentClient | cms.content | CRUD, publish, search, scheduled publishing |
| RevisionsClient | cms.revisions | List, diff, restore content revisions |
| SearchClient | cms.search | Cross-type full-text search |
| TenantsClient | cms.tenants | Members, limits, usage, deletion |
| MediaClient | cms.media | Folders, assets, uploads, tags, search, transforms |
| WebhooksClient | cms.webhooks | Register, list, delivery logs, retry |
| AuthorsClient | cms.authors | Author profile CRUD |
| InvitationsClient | cms.invitations | Create, accept, list invitations |
Authentication
Auth modes: The SDK supports three auth modes:
- Supabase (default) — pass
supabaseUrl+supabaseAnonKeyfor interactive sign-in/sign-up- Custom provider — pass
getTokenfunction for Firebase, Auth0, or any JWT provider- API token only — pass just
apiTokenfor server-side consumers (nosupabaseUrlneeded)
The SDK supports three authentication methods:
Supabase Session (interactive users)
// Sign up
const { user, session } = await cms.auth.signUp('[email protected]', 'password123')
// Sign in
const { user, session } = await cms.auth.signIn('[email protected]', 'password123')
// Session management
const session = await cms.auth.session()
const user = await cms.auth.getUser()
const token = await cms.auth.getAccessToken()
await cms.auth.signOut()After signing in, all subsequent API calls include the Bearer token automatically.
API Token (server-side)
Pass apiToken in the constructor config. The token is used as the Bearer token directly, bypassing Supabase session auth. If both apiToken and a Supabase session exist, the API token takes precedence.
const cms = new CmsClient({
baseUrl: 'https://cms.example.com',
tenant: 'my-org',
supabaseUrl: 'https://xxxx.supabase.co',
supabaseAnonKey: 'your-anon-key',
apiToken: 'lrm_7kX9mPqR2vN4bT8wJ6cF3hL5gD0sY1aE4uI9oM2xZ7nQ',
})API Tokens
API tokens enable server-side and machine-to-machine authentication. They are managed via the CMS API (requires admin/owner role).
Token format: lrm_ prefix + 48 random bytes (base62 encoded). The raw token is returned once at creation and never stored — only a SHA-256 hash is kept.
Token permissions: Tokens carry a role (viewer, editor, admin, owner) and go through the same 3-layer RBAC system as user sessions, including permission overrides.
# Create a read-only token
curl -X POST https://cms.example.com/api/v1/tenants/my-org/tokens \
-H "Authorization: Bearer <supabase-jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "production-website", "role": "viewer"}'
# Response includes the raw token (save it — shown only once)
# { "id": "uuid", "token": "lrm_...", "role": "viewer", ... }
# List tokens (raw token is never shown again)
curl https://cms.example.com/api/v1/tenants/my-org/tokens \
-H "Authorization: Bearer <supabase-jwt>"
# Revoke a token (immediate effect)
curl -X DELETE https://cms.example.com/api/v1/tenants/my-org/tokens/<token-id> \
-H "Authorization: Bearer <supabase-jwt>"Content
// Create (draft by default)
const entry = await cms.content.create('posts', { title: 'Hello', body: 'World' })
// Create with status
const published = await cms.content.create('posts', { title: 'Live' }, 'published')
// Schedule for future publishing
const scheduled = await cms.content.create(
'posts',
{ title: 'Future post' },
'scheduled',
'2026-06-01T09:00:00Z'
)
// List with filtering and search
const { entries, total } = await cms.content.list('posts', {
status: 'published',
q: 'search term',
limit: 10,
offset: 0,
})
// Get single entry
const post = await cms.content.get('posts', 'entry-uuid')
// Update
await cms.content.update('posts', entry.id, {
data: { title: 'Updated Title' },
status: 'published',
})
// Publish
await cms.content.publish('posts', entry.id)
// Delete
await cms.content.delete('posts', entry.id)Revisions
Every content mutation creates a revision. You can browse history, compare versions, and restore.
// List revisions for an entry
const { revisions, total } = await cms.revisions.list('posts', entry.id)
// Get a specific revision
const revision = await cms.revisions.get('posts', entry.id, 3)
// Diff two versions
const diff = await cms.revisions.diff('posts', entry.id, 1, 3)
// diff.changes: [{ field: 'title', type: 'changed', from: 'Old', to: 'New' }]
// Restore to a previous version
const restored = await cms.revisions.restore('posts', entry.id, 1)Search
// Search across all content types in the tenant
const results = await cms.search.query('hello world', {
status: 'published',
limit: 10,
})
// results.entries: [{ id, contentTypeId, score, data }]You can also search within a specific content type using the content client:
const { entries } = await cms.content.list('posts', { q: 'hello' })Tenants & Members
// List tenants you belong to
const { tenants } = await cms.tenants.myTenants()
// Manage members
const { members } = await cms.tenants.listMembers()
await cms.tenants.addMember({ userId: 'uuid', email: '[email protected]', role: 'editor' })
await cms.tenants.updateMemberRole('user-uuid', 'admin')
await cms.tenants.removeMember('user-uuid')
// Limits and usage
const { limits } = await cms.tenants.limits()
const usage = await cms.tenants.usage()
// usage.usage.entries: { current: 42, max: 100 }Media
Direct File Upload
Upload files directly via multipart/form-data. The server streams to R2 and auto-extracts metadata (dimensions, EXIF, blurhash, SHA-256 hash) in the background.
// Upload a file directly (recommended)
const asset = await cms.media.uploadFile(file, {
folderId: folder.id,
altText: 'A landscape photo',
custom: { credit: 'Photo by Jane' },
tags: ['hero', 'landscape'],
})Presigned URL Upload (Large Files)
For files over 100MB or direct browser-to-R2 uploads:
// Step 1: Get a presigned upload URL
const { uploadUrl, assetId } = await cms.media.requestUploadUrl({
filename: 'video.mp4',
mimeType: 'video/mp4',
sizeBytes: 52428800,
})
// Step 2: Upload directly to R2 (bypasses the Worker)
await fetch(uploadUrl, { method: 'PUT', body: file })
// Step 3: Confirm the upload
const asset = await cms.media.confirmUpload(assetId)Legacy JSON Upload
The original JSON-body upload still works for backwards compatibility:
const asset = await cms.media.createAsset({
filename: 'photo.jpg',
mimeType: 'image/jpeg',
url: 'https://r2.example.com/photo.jpg',
sizeBytes: 204800,
folderId: folder.id,
altText: 'A landscape photo',
})Folders
// Create a folder
const folder = await cms.media.createFolder({ name: 'Photos', slug: 'photos' })
// List root folders
const { folders } = await cms.media.listFolders()
// Get folder with children and assets
const { folder: f, children, assets } = await cms.media.getFolder(folder.id)
// Update and delete
await cms.media.updateFolder(folder.id, { name: 'Renamed' })
await cms.media.deleteFolder(folder.id)Tags
Assets can be tagged with flat string labels for organization and filtering.
// Upload with tags
const asset = await cms.media.uploadFile(file, { tags: ['hero', 'banner'] })
// Update tags (replaces all tags)
await cms.media.updateAsset(asset.id, { tags: ['hero', 'featured'] })
// List all tags in use (with asset counts, for autocomplete)
const { tags } = await cms.media.listTags()
// tags: [{ name: 'hero', count: 12 }, { name: 'banner', count: 5 }]Tags are normalized to lowercase, trimmed, and deduplicated automatically. Max 20 tags per asset.
Search
Full-text search across filenames, alt text, and tags — with combinable filters.
// Search by keyword
const results = await cms.media.search({ q: 'sunset beach' })
// Search with tag filter (assets must have ALL specified tags)
const results = await cms.media.search({
q: 'landscape',
tags: ['hero', 'featured'],
mimeType: 'image/jpeg',
limit: 10,
})
// Filter only (no full-text search)
const results = await cms.media.search({ tags: ['banner'], folderId: folder.id })Asset Management
// List assets with filters (including tags)
const { assets } = await cms.media.listAssets({
folderId: folder.id,
mimeType: 'image/jpeg',
tags: ['hero'],
status: 'ready', // 'ready' (default), 'pending', or 'all'
})
// Get asset details (includes metadata, duplicate info, and reference count)
const asset = await cms.media.getAsset(asset.id)
// asset.metadata: { width, height, format, blurhash, exif, custom }
// asset.contentHash: "sha256:abc123..."
// asset.duplicateOf: "other-asset-id" or null
// asset.tags: ["hero", "featured"]
// asset.referenceCount: 3
// Get a transformed image URL
const { url } = await cms.media.getTransformUrl(asset.id, {
width: 300,
format: 'webp',
quality: 80,
})
// Update metadata and tags
await cms.media.updateAsset(asset.id, {
altText: 'Updated alt text',
tags: ['hero', 'updated'],
})
// Delete (returns 400 if asset is referenced by content entries)
await cms.media.deleteAsset(asset.id)
// Use force to delete even if referenced:
// DELETE /api/v1/:tenant/media/assets/:id?force=trueUsage Tracking
See which content entries reference an asset.
// Get references
const { references, total } = await cms.media.getReferences(asset.id)
// references: [{ entryId, contentTypeId, contentTypeName: 'posts', fieldName: 'heroImage', createdAt }]Batch Operations
Perform operations on multiple assets at once (max 100 per request).
// Batch move to a folder
await cms.media.batch({
action: 'move',
assetIds: ['id1', 'id2', 'id3'],
params: { folderId: 'target-folder-id' },
})
// Batch delete (respects reference checks)
await cms.media.batch({
action: 'delete',
assetIds: ['id1', 'id2'],
params: { force: false },
})
// Batch tag (add/remove tags)
await cms.media.batch({
action: 'tag',
assetIds: ['id1', 'id2'],
params: { add: ['featured'], remove: ['draft'] },
})
// Returns: { results: [{ id, status: 'ok'|'error' }], succeeded: 2, failed: 0 }Authors
// Create (linked to current user by default)
const profile = await cms.authors.create({
displayName: 'Jane Doe',
bio: 'Writer and editor',
socialLinks: { twitter: '@janedoe' },
})
// Create a guest author (not linked to a user)
const guest = await cms.authors.create({
displayName: 'External Contributor',
userId: null,
})
// List, get, update, delete
const { authors } = await cms.authors.list()
const author = await cms.authors.get(profile.id)
await cms.authors.update(profile.id, { bio: 'Updated bio' })
await cms.authors.delete(profile.id)Invitations
// Create an email-targeted invite
const invite = await cms.invitations.create({
email: '[email protected]',
role: 'editor',
expiresInHours: 72,
})
// Create a code-based invite (shareable link)
const openInvite = await cms.invitations.create({
role: 'viewer',
maxUses: 10,
})
// openInvite.code -> share this code
// List and revoke
const { invitations } = await cms.invitations.list()
await cms.invitations.revoke(invite.id)
// Accept an invite
await cms.invitations.accept('invite-code-here')
// List invites sent to your email
const { invitations: mine } = await cms.invitations.mine()Webhooks
// Register
await cms.webhooks.register('content.created', 'https://your-app.com/hooks/new-post')
await cms.webhooks.register('*', 'https://your-app.com/hooks/all')
// List and delete
const { webhooks } = await cms.webhooks.list()
await cms.webhooks.delete('webhook-uuid')
// View delivery logs
const { deliveries } = await cms.webhooks.deliveries('webhook-uuid')
// Manually retry a failed delivery
await cms.webhooks.retryDelivery('webhook-uuid', 'delivery-uuid')Server Info & Adapter Discovery
Query the CMS backend to discover which adapters and capabilities are active:
const info = await cms.info()
console.log(info.adapters)
// { database: 'postgres', auth: ['supabase', 'jwt'], storage: 's3', transform: 'cf-images' }
console.log(info.capabilities)
// { search: true, transforms: true, presignedUploads: true }
// Feature detection
if (info.capabilities.presignedUploads) {
// Use presigned URL upload for large files
const { uploadUrl } = await cms.media.requestUploadUrl({ ... })
} else {
// Fall back to direct multipart upload
await cms.media.uploadFile(file)
}
if (info.capabilities.transforms) {
const { url } = await cms.media.getTransformUrl(asset.id, { width: 300, format: 'webp' })
}Adapter Combinations
The SDK works with any combination of backend adapters. Here's how the SDK config maps to different setups:
| Backend Auth | SDK Config |
|---|---|
| Supabase | supabaseUrl + supabaseAnonKey |
| Firebase | getToken: () => firebase.currentUser.getIdToken() |
| JWT / OAuth | getToken: () => yourAuthLib.getToken() |
| API token only | apiToken: 'lrm_...' |
| Any (server-side) | apiToken: process.env.CMS_TOKEN |
The SDK doesn't need to know which database or storage the backend uses — it communicates via the REST API. Use cms.info() to discover backend capabilities at runtime.
TypeScript Types
All types are exported for use in your application:
import type {
ContentEntry,
ContentType,
ContentRevision,
MediaAsset,
MediaFolder,
TransformParams,
UploadUrlResponse,
AssetReference,
BatchResult,
TagCount,
AuthorProfile,
Invitation,
TenantMember,
Webhook,
WebhookDelivery,
SearchResult,
} from '@theloremi/cms-client'Requirements
- A running Loremi CMS API instance (Cloudflare Workers, Node.js, Deno, or Bun)
- An auth provider configured on the CMS (Supabase, Firebase, JWT, or OAuth)
Content Type Relations
Content type fields support relationship cardinality via the relationType property:
// Creating a content type with relations
await cms.content.createType({
name: 'Articles',
slug: 'articles',
schema: {
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'author', type: 'relation', relationTo: 'authors', relationType: 'manyToOne' },
{ name: 'tags', type: 'relation', relationTo: 'tags', relationType: 'manyToMany' },
{ name: 'hero', type: 'relation', relationTo: 'heroes', relationType: 'oneToOne' },
],
},
})
// Creating an entry with relations
await cms.content.create('articles', {
title: 'Hello World',
author: 'uuid-of-author', // single UUID (manyToOne)
tags: ['uuid-tag-1', 'uuid-tag-2'], // UUID array (manyToMany)
hero: 'uuid-of-hero', // single UUID (oneToOne)
})| relationType | Stored as | Use case |
|---|---|---|
| oneToOne | Single UUID | Article → featured hero |
| manyToOne | Single UUID | Article → author (default when omitted) |
| oneToMany | UUID array | Author → articles |
| manyToMany | UUID array | Article → tags |
Omitting relationType defaults to manyToOne (single UUID) for backward compatibility.
Changelog
v0.9.3
- Content type relation fields now support
relationTypeproperty (oneToOne,oneToMany,manyToOne,manyToMany) - Array relations (
oneToMany,manyToMany) store UUID arrays in entry data - Single relations (
oneToOne,manyToOne) store a single UUID string (backward compatible) - SDK
ContentTypeFieldtype updated withrelationTypeproperty
v0.9.2
Backend stability and provisioning improvements (no SDK API changes):
- Cloudflare Hyperdrive integration for postgres connection pooling at the edge — eliminates cold-start hangs
- CORS origins now configurable via
CORS_ORIGINSenv var (comma-separated) - R2 storage auto-detected when
R2_BUCKETbinding is present DatabaseAdapter.clientproperty exposed across all 4 database adapters- Top-level error handler returns JSON with CORS headers (prevents browser-blocked errors)
- S3 adapter converted to lazy imports (AWS SDK only loaded when S3 is actually used)
- Error responses include
debugfield with actual error message for easier troubleshooting
v0.9.1
Backend performance and reliability improvements (no SDK API changes):
- Webhook retry logic fixed (was stuck in infinite retry loops)
- Batch tag/delete operations now execute as true batch queries (faster)
- Optimistic locking (
expectedVersion) now enforced at database level - Rate limiter hardened against memory exhaustion under high traffic
- Content operations no longer fail when webhook dispatch errors
- Database connection pool tuned (1 connection per Workers isolate, cached at module level)
v0.9.0
Initial release with full multi-tenant CMS API coverage.
License
ISC
