npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-client

Quick 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 + supabaseAnonKey for interactive sign-in/sign-up
  • Custom provider — pass getToken function for Firebase, Auth0, or any JWT provider
  • API token only — pass just apiToken for server-side consumers (no supabaseUrl needed)

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=true

Usage 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 relationType property (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 ContentTypeField type updated with relationType property

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_ORIGINS env var (comma-separated)
  • R2 storage auto-detected when R2_BUCKET binding is present
  • DatabaseAdapter.client property 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 debug field 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