Getting Started
This guide will walk you through creating your first Cloudillo application.
Prerequisites
- Node.js 18+ and pnpm installed
- Basic knowledge of TypeScript/JavaScript
- Familiarity with React (optional, for UI apps)
Installation
For Standalone Apps
For React Apps
pnpm add @cloudillo/core @cloudillo/react
For Real-Time Database
For Collaborative Editing
pnpm add @cloudillo/core yjs y-websocket
Your First App: Hello Cloudillo
Let’s create a simple app that displays the current user’s profile.
Step 1: Initialize the App
Create src/index.ts:
import { getAppBus } from '@cloudillo/core'
async function main() {
// Get the singleton message bus
const bus = getAppBus()
// Initialize with your app name
await bus.init('hello-cloudillo')
console.log('Initialized successfully!')
console.log('Access Token:', bus.accessToken)
console.log('User ID:', bus.idTag)
console.log('Tenant ID:', bus.tnId)
console.log('Roles:', bus.roles)
}
main().catch(console.error)
Step 2: Fetch User Profile
import { getAppBus, createApiClient } from '@cloudillo/core'
async function main() {
const bus = getAppBus()
await bus.init('hello-cloudillo')
// Create an API client
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// Fetch the current user's profile
const profile = await api.profiles.getOwn()
console.log('Profile:', profile)
console.log('Name:', profile.name)
console.log('ID Tag:', profile.idTag)
console.log('Profile Picture:', profile.profilePic)
}
main().catch(console.error)
Step 3: Create a Post
import { getAppBus, createApiClient } from '@cloudillo/core'
async function main() {
const bus = getAppBus()
await bus.init('hello-cloudillo')
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// Create a new post
const newPost = await api.actions.create({
type: 'POST',
content: {
text: 'Hello from my first Cloudillo app!',
title: 'My First Post'
}
})
console.log('Post created:', newPost)
}
main().catch(console.error)
React Example
For React applications, use the provided hooks:
import React from 'react'
import { useCloudillo, useAuth, useApi } from '@cloudillo/react'
function App() {
// useCloudillo handles initialization
const { token } = useCloudillo('hello-cloudillo')
if (!token) return <div>Loading...</div>
return <Profile />
}
function Profile() {
const [auth] = useAuth() // Returns tuple [auth, setAuth]
const { api } = useApi() // Returns { api, authenticated, setIdTag }
const [profile, setProfile] = React.useState(null)
React.useEffect(() => {
if (!api) return
api.profiles.getOwn().then(setProfile)
}, [api])
if (!api) return <div>No API client</div>
if (!profile) return <div>Loading...</div>
return (
<div>
<h1>Welcome, {profile.name}!</h1>
<p>ID: {auth?.idTag}</p>
{profile.profilePic && (
<img src={profile.profilePic} alt="Profile" />
)}
</div>
)
}
export default App
Real-Time Database Example
Here’s how to use the real-time database:
import { getAppBus, getRtdbUrl } from '@cloudillo/core'
import { RtdbClient } from '@cloudillo/rtdb'
async function main() {
const bus = getAppBus()
await bus.init('rtdb-example')
// Create RTDB client
const rtdb = new RtdbClient({
dbId: 'my-database-file-id',
auth: { getToken: () => bus.accessToken },
serverUrl: getRtdbUrl(bus.idTag!, 'my-database-file-id', bus.accessToken!)
})
// Connect to the database
await rtdb.connect()
// Get a collection reference
const todos = rtdb.collection('todos')
// Subscribe to real-time updates
todos.onSnapshot((snapshot) => {
console.log('Todos updated:', snapshot.docs.map(doc => doc.data()))
})
// Create a document using batch
const batch = rtdb.batch()
batch.create(todos, {
title: 'Learn Cloudillo',
completed: false,
createdAt: Date.now()
})
await batch.commit()
// Query documents
const incompleteTodos = await todos.query({
filter: { equals: { completed: false } },
sort: [{ field: 'createdAt', ascending: false }]
})
console.log('Incomplete todos:', incompleteTodos)
}
main().catch(console.error)
Collaborative Editing Example
Create a collaborative text editor:
import { getAppBus, openYDoc } from '@cloudillo/core'
import * as Y from 'yjs'
async function main() {
const bus = getAppBus()
await bus.init('collab-editor')
// Create a Yjs document
const yDoc = new Y.Doc()
// Open collaborative document (format: ownerTag:documentId)
const { provider } = await openYDoc(yDoc, 'owner.cloudillo.net:my-doc-id')
// Get shared text
const yText = yDoc.getText('content')
// Listen for changes
yText.observe(() => {
console.log('Text changed:', yText.toString())
})
// Insert text
yText.insert(0, 'Hello, collaborative world!')
// See awareness (other users' cursors/selections)
provider.awareness.on('change', () => {
const states = provider.awareness.getStates()
console.log('Connected users:', states.size)
})
}
main().catch(console.error)
Microfrontend Integration
If you’re building an app to run inside the Cloudillo shell:
import { getAppBus, createApiClient } from '@cloudillo/core'
async function main() {
// Get the singleton message bus
const bus = getAppBus()
// init() automatically handles the shell protocol
await bus.init('my-microfrontend')
// Create an API client
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// The shell provides via bus properties:
// - bus.idTag (user's identity)
// - bus.tnId (tenant ID)
// - bus.roles (user roles)
// - bus.darkMode (theme preference)
// - bus.access ('read' or 'write')
// Your app logic here...
}
main().catch(console.error)
Error Handling
All API calls can throw errors. Handle them appropriately:
import { getAppBus, createApiClient } from '@cloudillo/core'
const bus = getAppBus()
await bus.init('my-app')
try {
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
const profile = await api.profiles.getOwn()
} catch (error) {
if (error instanceof Error) {
console.error('Error:', error.message)
}
}
Next Steps
Now that you’ve created your first Cloudillo app, explore more features:
Common Patterns
Handling Dark Mode
import { getAppBus } from '@cloudillo/core'
const bus = getAppBus()
await bus.init('my-app')
// Check dark mode preference
if (bus.darkMode) {
document.body.classList.add('dark-theme')
}
Using Query Parameters
import { getAppBus, createApiClient } from '@cloudillo/core'
const bus = getAppBus()
await bus.init('my-app')
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// List actions with filters
const posts = await api.actions.list({
type: 'POST',
status: 'A', // Active
limit: 20
})
Uploading Files
import { getAppBus, createApiClient } from '@cloudillo/core'
const bus = getAppBus()
await bus.init('my-app')
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// Upload a file using the uploadBlob helper
const result = await api.files.uploadBlob(
'gallery', // preset
'image.png', // fileName
imageBlob, // file data
'image/png' // contentType
)
console.log('Uploaded file:', result.fileId)
Build and Deploy
Development
Most apps run as microfrontends inside the Cloudillo shell. Use your preferred build tool (Rollup, Webpack, Vite):
# Using Rollup (like the example apps)
pnpm build
# Using Vite
vite build
Production
Deploy your built app to any static hosting:
# The built output goes to the shell's apps directory
cp -r dist /path/to/cloudillo/shell/public/apps/my-app
Troubleshooting
“Failed to initialize”
Make sure you’re either:
- Running inside the Cloudillo shell (as a microfrontend), or
- Providing authentication manually for standalone apps
“CORS errors”
Ensure your Cloudillo server is configured to allow requests from your app’s origin.
“WebSocket connection failed”
Check that:
- The WebSocket URL is correct (wss:// for production)
- The server is running and accessible
- Your authentication token is valid
Example Apps
Check out the example apps in the Cloudillo repository:
- Quillo - Rich text editor with Quill
- Prello - Presentation tool
- Sheello - Spreadsheet application
- Formillo - Form builder
- Todollo - Task management
All use the same patterns described in this guide.
Authentication
Cloudillo uses JWT-based authentication with three types of tokens for different use cases. This guide explains how authentication works and how to use it in your applications.
Token Types
1. Access Token (Session Token)
The access token is your primary authentication credential for API requests.
Characteristics:
- JWT signed with ES384 elliptic curve algorithm
- Contains:
tnId (tenant ID), idTag (user identity), roles, iat (issued at), exp (expiration)
- Used for all authenticated API requests
- Typically valid for the duration of a session
Usage:
// The access token is automatically managed by @cloudillo/core
import * as cloudillo from '@cloudillo/core'
const token = await cloudillo.init('my-app')
// Token is now stored and used automatically for all API calls
// Or access it directly
console.log(cloudillo.accessToken)
2. Action Token (Federation Token)
Action tokens are cryptographically signed events used for federation between Cloudillo instances.
Characteristics:
- Represents a specific action (POST, CMNT, REACT, etc.)
- Signed by the issuer’s private key
- Can be verified by anyone with the issuer’s public key
- Enables trust-free federation
Usage:
// Action tokens are created automatically when you post actions
const api = cloudillo.createApiClient()
const action = await api.actions.create({
type: 'POST',
content: { text: 'Hello, world!' }
})
// The server automatically signs the action with your key
// Other instances can verify it without trusting your server
3. Proxy Token (Cross-Instance Token)
Proxy tokens enable accessing resources on remote Cloudillo instances.
Characteristics:
- Short-lived (typically 5 minutes)
- Grants read access to specific resources
- Used for federation scenarios
Usage:
const api = cloudillo.createApiClient()
// Get a proxy token for accessing a remote instance
const proxyToken = await api.auth.proxyToken.get()
// Use it to fetch resources from another instance
// (typically handled automatically by the client)
Authentication Flow
For Microfrontend Apps
When running inside the Cloudillo shell, authentication is handled automatically:
import * as cloudillo from '@cloudillo/core'
// The init() function receives the token from the shell via postMessage
const token = await cloudillo.init('my-app')
// All API calls now use this token automatically
const api = cloudillo.createApiClient()
const profile = await api.profiles.getOwn() // Authenticated request
For Standalone Apps
For standalone applications, you need to handle authentication manually:
import * as cloudillo from '@cloudillo/core'
// Option 1: Manual token management
cloudillo.accessToken = 'your-jwt-token-here'
const api = cloudillo.createApiClient()
// Option 2: Login flow
const response = await fetch('https://your-server.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idTag: 'alice@example.com',
password: 'secret123'
})
})
const { token, tnId, idTag, name, roles } = await response.json()
cloudillo.accessToken = token
cloudillo.tnId = tnId
cloudillo.idTag = idTag
cloudillo.roles = roles
Authentication Endpoints
POST /auth/register
Register a new user account.
Request:
{
"idTag": "alice@example.com",
"password": "secure-password",
"name": "Alice Johnson",
"profilePic": "https://example.com/alice.jpg"
}
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice Johnson",
"token": "eyJhbGc..."
}
}
POST /auth/login
Authenticate and receive an access token.
Request:
{
"idTag": "alice@example.com",
"password": "secure-password"
}
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice Johnson",
"profilePic": "/file/b1~abcd1234",
"roles": ["user", "admin"],
"token": "eyJhbGc...",
"settings": [
["theme", "dark"],
["language", "en"]
]
}
}
POST /auth/logout
Invalidate the current session.
Request:
POST /auth/logout
Authorization: Bearer eyJhbGc...
Response:
GET /auth/access-token
Exchange credentials for a scoped access token.
Query Parameters:
idTag - User identity
password - User password
roles - Optional: Requested roles (comma-separated)
ttl - Optional: Token lifetime in seconds
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735555555
}
}
GET /auth/proxy-token
Get a proxy token for accessing remote resources.
Request:
GET /auth/proxy-token?target=bob@remote.com
Authorization: Bearer eyJhbGc...
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735555555
}
}
Role-Based Access Control
Cloudillo supports role-based access control (RBAC) for fine-grained permissions.
Default Roles
- user - Standard user permissions (read/write own data)
- admin - Administrative permissions (manage server, users)
- read - Read-only access
- write - Write access to resources
Checking Roles
import * as cloudillo from '@cloudillo/core'
await cloudillo.init('my-app')
// Check if user has a specific role
if (cloudillo.roles?.includes('admin')) {
console.log('User is an admin')
}
// Enable/disable features based on roles
const canModerate = cloudillo.roles?.includes('admin') ||
cloudillo.roles?.includes('moderator')
Requesting Specific Roles
// Request an access token with specific roles
const response = await fetch(
'/auth/access-token?idTag=alice@example.com&password=secret&roles=user,admin'
)
Token Validation
All tokens are validated on the server for:
- Signature verification - Using ES384 algorithm
- Expiration check - Tokens expire after a set period
- Tenant isolation - Tokens are tied to specific tenants
- Role validation - Roles must be granted by the server
Security Best Practices
1. Token Storage
For web apps:
// Don't store tokens in localStorage (XSS vulnerable)
// ❌ localStorage.setItem('token', token)
// Use memory storage (managed by @cloudillo/core)
// ✅ cloudillo.accessToken = token
// Or use httpOnly cookies (server-side)
2. Token Renewal
// Implement token renewal before expiration
async function renewToken() {
const api = cloudillo.createApiClient()
try {
const newToken = await api.auth.loginToken.get()
cloudillo.accessToken = newToken.token
} catch (error) {
// Token expired, redirect to login
window.location.href = '/login'
}
}
// Renew every 50 minutes (if token lasts 60 minutes)
setInterval(renewToken, 50 * 60 * 1000)
3. Error Handling
import { FetchError } from '@cloudillo/core'
try {
const api = cloudillo.createApiClient()
const data = await api.profiles.getOwn()
} catch (error) {
if (error instanceof FetchError) {
if (error.code === 'E-AUTH-UNAUTH') {
// Unauthorized - token expired or invalid
window.location.href = '/login'
} else if (error.code === 'E-AUTH-FORBID') {
// Forbidden - insufficient permissions
alert('You do not have permission to access this resource')
}
}
}
4. HTTPS Only
Always use HTTPS in production:
// ✅ Good
const api = cloudillo.createApiClient({
baseUrl: 'https://api.cloudillo.com'
})
// ❌ Bad (only for local development)
const api = cloudillo.createApiClient({
baseUrl: 'http://localhost:3000'
})
WebAuthn Support
Cloudillo supports WebAuthn for passwordless authentication.
Registration Flow
// 1. Get registration options from server
const optionsResponse = await fetch('/auth/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idTag: 'alice@example.com' })
})
const options = await optionsResponse.json()
// 2. Create credential with browser WebAuthn API
const credential = await navigator.credentials.create({
publicKey: options
})
// 3. Verify credential with server
const verifyResponse = await fetch('/auth/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idTag: 'alice@example.com',
credential: {
id: credential.id,
rawId: Array.from(new Uint8Array(credential.rawId)),
response: {
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject))
},
type: credential.type
}
})
})
Authentication Flow
// 1. Get authentication options
const optionsResponse = await fetch('/auth/webauthn/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idTag: 'alice@example.com' })
})
const options = await optionsResponse.json()
// 2. Get assertion with browser WebAuthn API
const assertion = await navigator.credentials.get({
publicKey: options
})
// 3. Verify assertion with server
const verifyResponse = await fetch('/auth/webauthn/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idTag: 'alice@example.com',
credential: {
id: assertion.id,
rawId: Array.from(new Uint8Array(assertion.rawId)),
response: {
clientDataJSON: Array.from(new Uint8Array(assertion.response.clientDataJSON)),
authenticatorData: Array.from(new Uint8Array(assertion.response.authenticatorData)),
signature: Array.from(new Uint8Array(assertion.response.signature)),
userHandle: assertion.response.userHandle ?
Array.from(new Uint8Array(assertion.response.userHandle)) : null
},
type: assertion.type
}
})
})
const { token } = await verifyResponse.json()
cloudillo.accessToken = token
Multi-Tenant Considerations
Every request in Cloudillo is scoped to a tenant:
// The tnId is automatically included in all requests
console.log('Tenant ID:', cloudillo.tnId)
// Tokens are tenant-specific and cannot access other tenants' data
// This is enforced at the database level for security
Common Authentication Scenarios
Scenario 1: User Login
async function login(idTag: string, password: string) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idTag, password })
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
cloudillo.accessToken = data.data.token
cloudillo.tnId = data.data.tnId
cloudillo.idTag = data.data.idTag
cloudillo.roles = data.data.roles
return data.data
}
Scenario 2: Automatic Token Management
// @cloudillo/react handles this automatically
import { CloudilloProvider } from '@cloudillo/react'
function App() {
return (
<CloudilloProvider appName="my-app">
{/* Token is managed automatically */}
<YourApp />
</CloudilloProvider>
)
}
Scenario 3: Token Refresh
// Check token expiration and refresh
async function ensureAuthenticated() {
const api = cloudillo.createApiClient()
try {
// Try to use the current token
await api.profiles.getOwn()
} catch (error) {
if (error.code === 'E-AUTH-UNAUTH') {
// Token expired, get a new one
const { token } = await api.auth.loginToken.get()
cloudillo.accessToken = token
}
}
}
Next Steps
Client Libraries
Overview
Cloudillo provides a comprehensive set of TypeScript/JavaScript client libraries for building applications. These libraries handle authentication, API communication, real-time synchronization, and React integration.
Available Libraries
Core SDK for initialization and API access. This is the foundation for all Cloudillo applications.
Key Features:
- App initialization via message bus (
getAppBus())
- Type-safe REST API client
- URL helper functions
- Storage, settings, and media picker APIs
- Camera, document embedding, and CRDT cache management
Install:
React hooks for Cloudillo integration.
Key Features:
useAuth() hook for authentication state (returns tuple)
useApi() hook for API client access (returns object)
useCloudillo() hook for microfrontend initialization
useCloudilloEditor() hook for CRDT editors
useInfiniteScroll() hook for pagination
Install:
pnpm add @cloudillo/react
Shared TypeScript types with runtime validation using @symbion/runtype.
Key Features:
- All data types (Profile, Action, File, etc.)
- Runtime type validation
- Compile-time type safety
- Action type enums
- Type guards and validators
Install:
pnpm add @cloudillo/types
Real-time database client with Firebase-like API.
Key Features:
- Firebase-like API (familiar to developers)
- Real-time subscriptions
- Type-safe queries
- Batch operations
Install:
@cloudillo/crdt
CRDT document synchronization using Yjs with WebSocket transport.
Key Features:
openYDoc() for collaborative document editing
- WebSocket-based Yjs synchronization
- Offline caching and persistence support
Install:
React components and hooks for interactive object manipulation in SVG canvas applications.
Key Features:
- Transform gizmo with rotation, scaling, and positioning
- Rotation and pivot handle components
- Gradient picker with presets
- Coordinate and geometry utilities
Install:
pnpm add @cloudillo/canvas-tools
Font metadata and pairing suggestions for typography systems.
Key Features:
- Curated metadata for 22 Google Fonts
- Pre-defined font pairings (heading + body combinations)
- Helper functions for filtering by category and role
- Full TypeScript support
Install:
pnpm add @cloudillo/fonts
Quick Comparison
| Library |
Purpose |
Use When |
| @cloudillo/core |
Core functionality |
Every app |
| @cloudillo/react |
React integration |
Building React apps |
| @cloudillo/types |
Type definitions |
TypeScript projects |
| @cloudillo/crdt |
CRDT document sync |
Collaborative editing |
| @cloudillo/rtdb |
Real-time database |
Need structured real-time data |
| @cloudillo/canvas-tools |
SVG canvas manipulation |
Building drawing/design apps |
| @cloudillo/fonts |
Font metadata and pairings |
Typography selection UI |
Installation
Minimal Setup (vanilla JS)
React Setup
pnpm add @cloudillo/core @cloudillo/react
Full Setup (with real-time features)
pnpm add @cloudillo/core @cloudillo/react @cloudillo/crdt @cloudillo/rtdb yjs y-websocket
Basic Usage
With @cloudillo/core
import { getAppBus, createApiClient } from '@cloudillo/core'
// Get the singleton message bus
const bus = getAppBus()
// Initialize (communicates with shell)
await bus.init('my-app')
// Access state
console.log(bus.idTag) // User's identity
console.log(bus.accessToken) // JWT token
// Create API client
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// Make requests
const profile = await api.profiles.getOwn()
With @cloudillo/react
import { useCloudillo, useAuth, useApi } from '@cloudillo/react'
function App() {
// useCloudillo handles initialization
const { token, idTag, ownerTag, fileId } = useCloudillo('my-app')
if (!token) return <div>Loading...</div>
return <MyComponent />
}
function MyComponent() {
const [auth] = useAuth() // Returns tuple [auth, setAuth]
const { api } = useApi() // Returns { api, authenticated, setIdTag }
if (!api) return <div>Loading...</div>
// Use auth and api...
}
With @cloudillo/rtdb
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
const rtdb = new RtdbClient({
dbId: 'my-db-file-id',
auth: { getToken: () => bus.accessToken },
serverUrl: getRtdbUrl(bus.idTag!, 'my-db-file-id', bus.accessToken!)
})
await rtdb.connect()
const todos = rtdb.collection('todos')
todos.onSnapshot(snapshot => {
console.log(snapshot.docs.map(doc => doc.data()))
})
Common Patterns
Pattern 1: Authentication Flow
import { getAppBus } from '@cloudillo/core'
// Get message bus singleton
const bus = getAppBus()
// Initialize (gets token from shell)
const state = await bus.init('my-app')
// Access auth state via bus properties
console.log(bus.idTag) // User's identity
console.log(bus.tnId) // Tenant ID
console.log(bus.roles) // User roles
console.log(bus.accessToken) // JWT token
console.log(bus.access) // 'read' or 'write'
Pattern 2: API Requests
import { getAppBus, createApiClient } from '@cloudillo/core'
const bus = getAppBus()
await bus.init('my-app')
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
// GET requests
const profile = await api.profiles.getOwn()
// POST requests
const action = await api.actions.create({
type: 'POST',
content: { text: 'Hello!' }
})
// Query with parameters
const actions = await api.actions.list({
type: 'POST',
limit: 20
})
Pattern 3: Real-Time CRDT
import { getAppBus } from '@cloudillo/core'
import { openYDoc } from '@cloudillo/crdt'
import * as Y from 'yjs'
const bus = getAppBus()
await bus.init('my-app')
// Open CRDT document
const yDoc = new Y.Doc()
const { provider } = await openYDoc(yDoc, 'alice.cloudillo.net:doc-id')
// Use shared types
const yText = yDoc.getText('content')
yText.insert(0, 'Hello!')
// Listen for changes
yText.observe(() => {
console.log('Text updated:', yText.toString())
})
Pattern 4: React Integration
import { useCloudillo, useApi, useAuth } from '@cloudillo/react'
import { useEffect, useState } from 'react'
function App() {
const { token } = useCloudillo('my-app')
if (!token) return <div>Initializing...</div>
return <PostsList />
}
function PostsList() {
const { api } = useApi()
const [posts, setPosts] = useState([])
useEffect(() => {
if (!api) return
api.actions.list({ type: 'POST', limit: 20 })
.then(setPosts)
}, [api])
if (!api) return <div>Loading...</div>
return (
<div>
{posts.map(post => (
<div key={post.actionId}>{post.content?.text}</div>
))}
</div>
)
}
TypeScript Support
All libraries are written in TypeScript and provide full type definitions.
import type { Profile, Action, NewAction } from '@cloudillo/types'
import { createApiClient } from '@cloudillo/core'
// Types are automatically inferred
const api = createApiClient({ idTag: 'alice.cloudillo.net' })
const profile: Profile = await api.profiles.getOwn()
// Type-safe action creation
const newAction: NewAction = {
type: 'POST',
content: { text: 'Hello!' }
}
const created: Action = await api.actions.create(newAction)
Error Handling
API errors are thrown as standard errors with status codes:
import { createApiClient } from '@cloudillo/core'
try {
const api = createApiClient({ idTag: 'alice.cloudillo.net' })
const data = await api.profiles.getOwn()
} catch (error) {
if (error instanceof Error) {
console.error('Error:', error.message)
}
}
Library Details
Explore each library in detail:
Next Steps
Subsections of Client Libraries
@cloudillo/core
Overview
The @cloudillo/core library is the core SDK for Cloudillo applications. It provides initialization via the message bus, API client creation, CRDT document support, and URL helpers.
Installation
Core Pattern: AppMessageBus
The main API is accessed through the getAppBus() singleton. This provides authentication state, storage, and shell communication for apps running in the Cloudillo shell.
import { getAppBus } from '@cloudillo/core'
// Get the singleton message bus
const bus = getAppBus()
// Initialize your app (communicates with shell)
const state = await bus.init('my-app')
// Access state via bus properties
console.log('Token:', bus.accessToken)
console.log('User ID:', bus.idTag)
console.log('Tenant ID:', bus.tnId)
console.log('Roles:', bus.roles)
console.log('Access level:', bus.access)
console.log('Dark mode:', bus.darkMode)
AppState Properties
After calling bus.init(), these properties are available on the bus:
| Property |
Type |
Description |
accessToken |
string | undefined |
Current JWT access token |
idTag |
string | undefined |
User’s identity tag (e.g., “alice.cloudillo.net”) |
tnId |
number | undefined |
Tenant ID |
roles |
string[] | undefined |
User’s roles |
access |
'read' | 'write' |
Access level to current resource |
darkMode |
boolean |
Dark mode preference |
tokenLifetime |
number | undefined |
Token lifetime in seconds |
displayName |
string | undefined |
Display name (for anonymous guests) |
embedded |
boolean |
Whether the app is running as an embedded document |
theme |
string | undefined |
Theme name |
navState |
string | undefined |
Initial navigation state (from parent embed or shell) |
ancestors |
string[] | undefined |
Ancestor file IDs in the embed chain |
Storage API
The bus provides namespaced key-value storage:
const bus = getAppBus()
await bus.init('my-app')
// Store data
await bus.storage.set('my-app', 'settings', { theme: 'dark' })
// Retrieve data
const settings = await bus.storage.get<{ theme: string }>('my-app', 'settings')
// List keys
const keys = await bus.storage.list('my-app', 'user-')
// Delete data
await bus.storage.delete('my-app', 'settings')
// Clear namespace
await bus.storage.clear('my-app')
// Check quota
const quota = await bus.storage.quota('my-app')
console.log(`Used ${quota.used} of ${quota.limit} bytes`)
App Lifecycle Notifications
Notify the shell about your app’s loading progress:
const bus = getAppBus()
await bus.init('my-app')
// After auth init (called automatically by init())
bus.notifyReady('auth')
// After CRDT sync complete
bus.notifyReady('synced')
// When fully interactive
bus.notifyReady('ready')
Token Refresh
Request a fresh token when needed:
const bus = getAppBus()
// Manually refresh token
const newToken = await bus.refreshToken()
// Listen for token updates pushed from shell
bus.on('auth:token.push', (msg) => {
console.log('Token updated:', bus.accessToken)
})
Error Notification
Notify the shell about errors in your app:
bus.notifyError(404, 'Document not found')
Event Handling
Register and unregister message handlers:
bus.on('auth:token.push', handler)
bus.off('auth:token.push', handler)
Open the shell’s media picker dialog:
const result = await bus.pickMedia({
mediaType: 'image/*', // MIME filter
enableCrop: true, // Enable crop UI
cropAspects: ['16:9', '1:1'], // Allowed aspect ratios
documentFileId: 'abc123', // Context document
title: 'Choose image'
})
// Returns: { fileId, fileName, contentType, dim?, visibility?, croppedVariantId? }
Document Picker
Open the shell’s document picker:
const result = await bus.pickDocument({
fileTp: 'CRDT', // File type filter
contentType: 'cloudillo/quillo', // Content type filter
sourceFileId: 'abc123', // Source context
title: 'Choose document'
})
// Returns: { fileId, fileName, contentType, fileTp?, appId? }
Camera Capture
Capture an image from the device camera:
const result = await bus.captureImage({
facing: 'environment', // 'user' or 'environment'
maxResolution: 1920
})
// Returns: { imageData (base64), width, height }
Camera Preview
Open a camera preview with overlay support:
const session = await bus.openCamera({ facing: 'environment' })
// Preview frames
bus.previewFrames(session.sessionId, (frameData) => { /* ... */ })
// Add overlay shapes
bus.overlayShapes(session.sessionId, shapes)
// Wait for capture
const result = await session.result
Document Embedding
Request an embedded document view:
const { embedUrl, nonce, resId } = await bus.requestEmbed({
targetFileId: 'abc123',
targetContentType: 'cloudillo/quillo',
sourceFileId: 'current-doc',
access: 'read',
navState: 'page=3'
})
Settings API
Access user settings through the bus:
await bus.settings.get('key')
await bus.settings.set('key', value)
const items = await bus.settings.list('prefix')
CRDT Cache Management
Manage offline CRDT caching:
const clientId = await bus.requestClientId(docId)
await bus.crdtCacheAppend(docId, update, clientId, clock)
const updates = await bus.crdtCacheRead(docId)
await bus.crdtCacheCompact(docId, state)
resetAppBus()
Reset the singleton message bus instance (useful for testing):
import { resetAppBus } from '@cloudillo/core'
resetAppBus()
API Client
createApiClient(opts: ApiClientOpts): ApiClient
Create a type-safe REST API client.
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({
idTag: 'alice.cloudillo.net', // Required: target tenant
authToken: 'jwt-token' // Optional: authentication token
})
// Use the API
const profile = await api.profiles.getOwn()
const posts = await api.actions.list({ type: 'POST', limit: 20 })
Options:
interface ApiClientOpts {
idTag: string // Required: identity tag of the tenant
authToken?: string // Optional: JWT token for authentication
}
idTag is Required
Unlike the old documentation, idTag is required to create an API client. This specifies which Cloudillo instance to connect to.
Using with AppMessageBus
import { getAppBus, createApiClient } from '@cloudillo/core'
const bus = getAppBus()
const state = await bus.init('my-app')
const api = createApiClient({
idTag: bus.idTag!,
authToken: bus.accessToken
})
const files = await api.files.list({ limit: 20 })
CRDT Document Functions
openYDoc(yDoc: Y.Doc, docId: string): Promise<DocConnection>
Open a Yjs document for collaborative editing with WebSocket synchronization.
Info
openYDoc is exported from @cloudillo/crdt, not from @cloudillo/core.
import { getAppBus } from '@cloudillo/core'
import { openYDoc } from '@cloudillo/crdt'
import * as Y from 'yjs'
const bus = getAppBus()
await bus.init('my-app')
const yDoc = new Y.Doc()
const { yDoc: doc, provider } = await openYDoc(yDoc, 'alice.cloudillo.net:document-id')
// Use shared types
const yText = yDoc.getText('content')
yText.insert(0, 'Hello, collaborative world!')
// Listen for changes
yText.observe(() => {
console.log('Text changed:', yText.toString())
})
// Access awareness (other users' cursors, selections)
provider.awareness.on('change', () => {
const states = provider.awareness.getStates()
console.log('Connected users:', states.size)
})
Parameters:
yDoc - The Yjs document to synchronize
docId - Document ID in format "targetTag:resourceId"
Returns:
interface DocConnection {
yDoc: Y.Doc
provider: WebsocketProvider
}
Error Handling:
- Throws if no access token (must call
init() first)
- Throws if
docId format is invalid
- WebSocket close codes 4401/4403/4404 stop reconnection (auth/permission/not found errors)
URL Helper Functions
Build URLs for Cloudillo services:
getInstanceUrl(idTag: string): string
Build the base URL for a Cloudillo instance.
import { getInstanceUrl } from '@cloudillo/core'
const url = getInstanceUrl('alice.cloudillo.net')
// Returns: "https://cl-o.alice.cloudillo.net"
getApiUrl(idTag: string): string
Build the API base URL.
import { getApiUrl } from '@cloudillo/core'
const url = getApiUrl('alice.cloudillo.net')
// Returns: "https://cl-o.alice.cloudillo.net/api"
getFileUrl(idTag: string, fileId: string, variant?: string): string
Build URL for file access with optional variant.
import { getFileUrl } from '@cloudillo/core'
// Basic file URL
const url = getFileUrl('alice.cloudillo.net', 'file-123')
// Returns: "https://cl-o.alice.cloudillo.net/api/files/file-123"
// With variant
const thumbnailUrl = getFileUrl('alice.cloudillo.net', 'file-123', 'vis.tn')
// Returns: "https://cl-o.alice.cloudillo.net/api/files/file-123?variant=vis.tn"
getCrdtUrl(idTag: string): string
Build the CRDT WebSocket URL.
import { getCrdtUrl } from '@cloudillo/core'
const url = getCrdtUrl('alice.cloudillo.net')
// Returns: "wss://cl-o.alice.cloudillo.net/ws/crdt"
getRtdbUrl(idTag: string, fileId: string, token: string): string
Build the RTDB WebSocket URL with authentication.
import { getRtdbUrl } from '@cloudillo/core'
const url = getRtdbUrl('alice.cloudillo.net', 'db-file-id', 'jwt-token')
// Returns: "wss://cl-o.alice.cloudillo.net/ws/rtdb/db-file-id?token=jwt-token"
getMessageBusUrl(idTag: string, token: string): string
Build the Message Bus WebSocket URL.
import { getMessageBusUrl } from '@cloudillo/core'
const url = getMessageBusUrl('alice.cloudillo.net', 'jwt-token')
// Returns: "wss://cl-o.alice.cloudillo.net/ws/bus?token=jwt-token"
Image/Video Variant Helpers
getOptimalImageVariant(context, localVariants?): string
Get the optimal image variant for a display context.
import { getOptimalImageVariant } from '@cloudillo/core'
// For thumbnail display
const variant = getOptimalImageVariant('thumbnail') // 'vis.tn'
// For preview
const variant = getOptimalImageVariant('preview') // 'vis.sd'
// For fullscreen/lightbox
const variant = getOptimalImageVariant('fullscreen') // 'vis.hd'
// With available variants
const variant = getOptimalImageVariant('fullscreen', ['vis.sd', 'vis.hd', 'vis.xd'])
// Returns highest quality available: 'vis.xd'
Image variants: vis.tn (150px), vis.sd (640px), vis.md (1280px), vis.hd (1920px), vis.xd (original)
getOptimalVideoVariant(context, localVariants?): string
Get the optimal video variant for a display context.
import { getOptimalVideoVariant } from '@cloudillo/core'
const variant = getOptimalVideoVariant('preview') // 'vid.sd'
const variant = getOptimalVideoVariant('fullscreen') // 'vid.hd'
Video variants: vid.sd, vid.md, vid.hd, vid.xd
getImageVariantForDisplaySize(width, height): string
Get optimal variant for a specific display size in pixels.
import { getImageVariantForDisplaySize } from '@cloudillo/core'
// For a 300x200 canvas at 2x zoom (600x400 screen pixels)
const variant = getImageVariantForDisplaySize(600, 400)
// Returns: 'vis.sd'
API Client Structure
The API client provides access to all REST endpoints organized by namespace:
const api = createApiClient({ idTag: 'alice.cloudillo.net', authToken: token })
// ============================================================================
// AUTH - Authentication & Authorization
// ============================================================================
await api.auth.login({ idTag, password })
await api.auth.logout()
await api.auth.loginInit() // Combined init (auth or QR+WebAuthn)
await api.auth.getLoginToken()
await api.auth.getAccessToken({ scope, lifetime })
await api.auth.getAccessTokenByRef(refId) // Guest access via reference
await api.auth.getAccessTokenVia(via, scope) // Scoped token via cross-document link
await api.auth.getAccessTokenByApiKey(apiKey) // API key authentication
await api.auth.getProxyToken(targetIdTag) // Federation proxy token
await api.auth.getVapidPublicKey() // Push notification key
await api.auth.changePassword({ oldPassword, newPassword })
await api.auth.setPassword({ refId, password }) // Set password via reset link
await api.auth.forgotPassword({ email })
// WebAuthn
await api.auth.listWebAuthnCredentials()
await api.auth.getWebAuthnRegChallenge()
await api.auth.registerWebAuthnCredential({ token, credential })
await api.auth.deleteWebAuthnCredential(credentialId)
await api.auth.getWebAuthnLoginChallenge()
await api.auth.webAuthnLogin({ token, credential })
// API Keys
await api.auth.listApiKeys()
await api.auth.createApiKey({ name, scopes })
await api.auth.deleteApiKey(keyId)
// QR Login
await api.auth.initQrLogin() // Start QR login session
await api.auth.getQrLoginStatus(sessionId, secret) // Poll for login status
await api.auth.getQrLoginDetails(sessionId) // Get session details (mobile)
await api.auth.respondQrLogin(sessionId, data) // Approve/deny from mobile
// ============================================================================
// PROFILE - Registration & Profile Creation
// ============================================================================
await api.profile.verify({ type, idTag, email }) // Check availability
await api.profile.register({ type, idTag, name, email, password })
// ============================================================================
// PROFILES - Profile Management
// ============================================================================
await api.profiles.getOwn() // GET /me
await api.profiles.getOwnFull() // GET /me/full
await api.profiles.getRemoteFull(idTag) // GET full profile from remote server
await api.profiles.updateOwn({ name, bio }) // PATCH /me
await api.profiles.list({ q, type, role }) // List/search profiles
await api.profiles.get(idTag) // Get profile by idTag
await api.profiles.updateConnection(idTag, data) // Update relationship
await api.profiles.adminUpdate(idTag, { status, roles }) // Admin operations
// ============================================================================
// ACTIONS - Social Interactions
// ============================================================================
await api.actions.list({ type, status, limit }) // List actions
await api.actions.listPaginated({ cursor, limit }) // With cursor pagination
await api.actions.create({ type, content, ... }) // Create action
await api.actions.get(actionId) // Get single action
await api.actions.update(actionId, patch) // Update draft
await api.actions.delete(actionId) // Delete action
await api.actions.accept(actionId) // Accept (e.g., connection)
await api.actions.reject(actionId) // Reject
await api.actions.dismiss(actionId) // Dismiss notification
await api.actions.updateStat(actionId, stat) // Update statistics
await api.actions.addReaction(actionId, { type }) // Add reaction
await api.actions.publish(actionId, { publishAt }) // Publish draft (optionally scheduled)
await api.actions.cancel(actionId) // Cancel scheduled (revert to draft)
// ============================================================================
// FILES - File Management
// ============================================================================
await api.files.list({ parentId, tag, limit }) // List files
await api.files.listPaginated({ cursor, limit }) // With cursor pagination
await api.files.create({ fileName, fileTp }) // Create metadata-only file
await api.files.uploadBlob(preset, name, data, contentType) // Upload file
await api.files.get(fileId) // Get file content
await api.files.getVariant(variantId) // Get specific variant
await api.files.getDescriptor(fileId) // Get file metadata
await api.files.update(fileId, { fileName }) // Update metadata
await api.files.delete(fileId) // Soft delete (to trash)
await api.files.permanentDelete(fileId) // Permanent delete
await api.files.restore(fileId, parentId) // Restore from trash
await api.files.updateUserData(fileId, { starred }) // Update user-specific data
await api.files.setStarred(fileId, true) // Toggle starred
await api.files.setPinned(fileId, true) // Toggle pinned
await api.files.addTag(fileId, 'tag') // Add tag
await api.files.removeTag(fileId, 'tag') // Remove tag
await api.files.duplicate(fileId, { fileName }) // Duplicate a CRDT/RTDB file
await api.files.listShares(fileId) // List share entries for file
await api.files.createShare(fileId, data) // Create share entry
await api.files.deleteShare(fileId, shareId) // Delete share entry
// ============================================================================
// SHARES - Share Entry Queries
// ============================================================================
await api.shares.listBySubject(subjectId, subjectType) // List shares by subject
// ============================================================================
// TRASH - Trash Management
// ============================================================================
await api.trash.list({ limit }) // List trashed files
await api.trash.empty() // Empty trash permanently
// ============================================================================
// TAGS - Tag Management
// ============================================================================
await api.tags.list({ prefix, withCounts }) // List tags
// ============================================================================
// SETTINGS - User Settings
// ============================================================================
await api.settings.list({ prefix }) // List settings
await api.settings.get(name) // Get setting value
await api.settings.update(name, { value }) // Update setting
// ============================================================================
// NOTIFICATIONS - Push Notifications
// ============================================================================
await api.notifications.subscribe({ subscription }) // Subscribe to push
// ============================================================================
// REFS - Share Links & References
// ============================================================================
await api.refs.list({ type, resourceId }) // List references
await api.refs.get(refId) // Get reference
await api.refs.create({ type, resourceId, access }) // Create share link
await api.refs.delete(refId) // Delete reference
// ============================================================================
// IDP - Identity Provider (End User)
// ============================================================================
await api.idp.getInfo(providerDomain) // Get provider info
await api.idp.activate({ refId }) // Activate identity
// ============================================================================
// IDP MANAGEMENT - Identity Provider Admin
// ============================================================================
await api.idpManagement.listIdentities({ q, status })
await api.idpManagement.createIdentity({ idTag, email, createApiKey })
await api.idpManagement.getIdentity(idTag)
await api.idpManagement.updateIdentity(idTag, { dyndns })
await api.idpManagement.deleteIdentity(idTag)
await api.idpManagement.listApiKeys(idTag)
await api.idpManagement.createApiKey({ idTag, name })
await api.idpManagement.deleteApiKey(keyId, idTag)
// ============================================================================
// COMMUNITIES - Community Management
// ============================================================================
await api.communities.create(idTag, { type, name, ownerIdTag })
await api.communities.verify({ type, idTag }) // Deprecated: use profile.verify
// ============================================================================
// ADMIN - System Administration
// ============================================================================
await api.admin.listTenants({ q, status, limit }) // List all tenants
await api.admin.sendPasswordReset(idTag) // Send password reset
await api.admin.sendTestEmail(to) // Test SMTP config
await api.admin.listProxySites() // List proxy site configs
await api.admin.createProxySite(data) // Create proxy site
await api.admin.getProxySite(siteId) // Get proxy site
await api.admin.updateProxySite(siteId, data) // Update proxy site
await api.admin.deleteProxySite(siteId) // Delete proxy site
await api.admin.renewProxySiteCert(siteId) // Renew proxy site TLS cert
Helper Functions
delay(ms: number): Promise<void>
Delay execution for a specified time.
import { delay } from '@cloudillo/core'
await delay(1000) // Wait 1 second
See Also
@cloudillo/react
Overview
React hooks and components for integrating Cloudillo into React applications.
Installation
pnpm add @cloudillo/react @cloudillo/core
Hooks
useAuth()
Access authentication state using Jotai atoms. Returns a tuple [auth, setAuth] for reading and updating auth state.
import { useAuth } from '@cloudillo/react'
function UserInfo() {
const [auth, setAuth] = useAuth()
if (!auth?.idTag) {
return <div>Not authenticated</div>
}
return (
<div>
<p>User: {auth.idTag}</p>
<p>Name: {auth.name}</p>
<p>Tenant: {auth.tnId}</p>
<p>Roles: {auth.roles?.join(', ')}</p>
{auth.profilePic && <img src={auth.profilePic} alt="Profile" />}
</div>
)
}
Returns: [AuthState | undefined, SetAtom<AuthState>]
interface AuthState {
tnId: number // Tenant ID
idTag?: string // Identity tag (e.g., "alice.cloudillo.net")
name?: string // Display name
profilePic?: string // Profile picture ID
roles?: string[] // Community roles
token?: string // JWT access token
}
Usage patterns:
// Destructure the tuple
const [auth, setAuth] = useAuth()
// Check if user is authenticated
if (!auth?.idTag) {
return <LoginPrompt />
}
// Check for specific role
if (auth?.roles?.includes('admin')) {
return <AdminPanel />
}
// Update auth state (typically done by useCloudillo)
setAuth({
tnId: 123,
idTag: 'alice.cloudillo.net',
token: 'jwt-token'
})
useApi()
Get a type-safe API client with automatic caching per idTag/token combination.
import { useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'
function PostsList() {
const { api, authenticated, setIdTag } = useApi()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!api) return
api.actions.list({ type: 'POST', limit: 20 })
.then(setPosts)
.finally(() => setLoading(false))
}, [api])
if (!api) return <div>No API client (no idTag)</div>
if (!authenticated) return <div>Please log in</div>
if (loading) return <div>Loading...</div>
return (
<div>
{posts.map(post => (
<div key={post.actionId}>{post.content}</div>
))}
</div>
)
}
Returns:
interface ApiHook {
api: ApiClient | null // Type-safe API client (null if no idTag)
authenticated: boolean // Whether user has a token
setIdTag: (idTag: string) => void // Set idTag for login flow
}
The API client is the same as returned by createApiClient() from @cloudillo/core.
api can be null
api is null until an idTag is available (either from auth state or set via setIdTag). Always check for null before using.
useCloudillo()
Unified hook for microfrontend app initialization. Handles shell communication, authentication, and document context.
import { useCloudillo } from '@cloudillo/react'
function MyApp() {
const { token, ownerTag, fileId, idTag, roles, access, displayName } = useCloudillo('my-app')
if (!token) return <div>Loading...</div>
return (
<div>
<p>Document: {fileId}</p>
<p>Owner: {ownerTag}</p>
<p>Access: {access}</p>
<p>User: {idTag}</p>
</div>
)
}
This hook:
- Calls
getAppBus().init(appName) on mount
- Parses
ownerTag and fileId from location.hash (format: #ownerTag:fileId)
- Updates auth state via
useAuth()
- Returns combined state from bus and URL
Returns:
interface UseCloudillo {
token?: string // Access token
ownerTag: string // Owner of the current document (from URL hash)
fileId?: string // Current file/document ID (from URL hash)
idTag?: string // Current user's identity tag
tnId?: number // Tenant ID
roles?: string[] // User's roles
access?: 'read' | 'write' // Access level to current resource
displayName?: string // User's display name (for anonymous guests)
}
useCloudilloEditor()
Extended hook for CRDT-based collaborative document editing. Returns everything from useCloudillo() plus Yjs document and sync state.
import { useCloudilloEditor } from '@cloudillo/react'
function CollaborativeEditor() {
const { token, yDoc, provider, synced, ownerTag, fileId, access } = useCloudilloEditor('quillo')
if (!synced) return <LoadingSpinner />
// Use yDoc with your editor binding (Quill, TipTap, Monaco, etc.)
const yText = yDoc.getText('content')
return <QuillEditor yText={yText} provider={provider} />
}
This hook:
- Calls
useCloudillo(appName) internally
- Opens CRDT connection via
openYDoc() from @cloudillo/crdt when token and docId are available
- Listens for sync events and notifies shell via
bus.notifyReady('synced')
- Cleans up WebSocket provider on unmount
Returns:
interface UseCloudilloEditor extends UseCloudillo {
yDoc: Y.Doc // Yjs document instance
provider?: WebsocketProvider // WebSocket sync provider
synced: boolean // Whether initial sync is complete
error: { code: number; reason?: string } | null // Connection error state
}
Cursor-based infinite scroll pagination with IntersectionObserver.
import { useInfiniteScroll } from '@cloudillo/react'
function FileList() {
const { api } = useApi()
const { items, isLoading, hasMore, sentinelRef, prepend, reset } = useInfiniteScroll({
fetchPage: async (cursor, limit) => {
const result = await api.files.listPaginated({ cursor, limit })
return {
items: result.data,
nextCursor: result.cursorPagination?.nextCursor ?? null,
hasMore: result.cursorPagination?.hasMore ?? false
}
},
pageSize: 30,
deps: [folderId, sortField] // Reset when these change
})
return (
<div>
{items.map(file => <FileCard key={file.fileId} file={file} />)}
{/* Sentinel triggers loadMore when visible */}
<div ref={sentinelRef} />
{isLoading && <LoadingSpinner />}
</div>
)
}
Options:
interface UseInfiniteScrollOptions<T> {
fetchPage: (cursor: string | null, limit: number) => Promise<{
items: T[]
nextCursor: string | null
hasMore: boolean
}>
pageSize?: number // Default: 20
deps?: React.DependencyList // Reset when these change
enabled?: boolean // Default: true
}
Returns:
interface UseInfiniteScrollReturn<T> {
items: T[] // All loaded items
isLoading: boolean // Initial load in progress
isLoadingMore: boolean // Loading more pages
error: Error | null // Last fetch error
hasMore: boolean // More items available
loadMore: () => void // Manually load next page
reset: () => void // Reset and reload
prepend: (items: T[]) => void // Add items to start (real-time)
sentinelRef: RefObject<HTMLDivElement> // Attach to trigger element
}
useDebouncedValue()
Debounce a rapidly changing value:
import { useDebouncedValue } from '@cloudillo/react'
function SearchField() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebouncedValue(query, 300) // 300ms delay
useEffect(() => {
if (debouncedQuery) {
api.profiles.list({ q: debouncedQuery })
}
}, [debouncedQuery])
return <Input value={query} onChange={(e) => setQuery(e.target.value)} />
}
useDocumentEmbed()
Manage embedded document state for cross-document embedding:
import { useDocumentEmbed } from '@cloudillo/react'
function EmbeddedDoc({ fileId, contentType }) {
const { embedUrl, loading, error } = useDocumentEmbed({
targetFileId: fileId,
targetContentType: contentType,
sourceFileId: currentFileId
})
if (loading) return <LoadingSpinner />
if (error) return <div>Embed error: {error.message}</div>
return <iframe src={embedUrl} />
}
Common Patterns
Pattern 1: Microfrontend App
The typical pattern for a Cloudillo microfrontend app:
import { useCloudillo, useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'
function App() {
const { token, idTag, ownerTag, fileId, access } = useCloudillo('my-app')
const { api } = useApi()
const [data, setData] = useState(null)
useEffect(() => {
if (!api || !fileId) return
api.files.getDescriptor(fileId)
.then(setData)
.catch(console.error)
}, [api, fileId])
if (!token) return <div>Initializing...</div>
if (!data) return <div>Loading...</div>
return (
<div>
<h1>{data.fileName}</h1>
<p>Owner: {ownerTag}</p>
<p>Access: {access}</p>
</div>
)
}
Pattern 2: Collaborative Editor
import { useCloudilloEditor } from '@cloudillo/react'
import { useEffect } from 'react'
function Editor() {
const { yDoc, provider, synced, access } = useCloudilloEditor('my-editor')
useEffect(() => {
if (!synced) return
const yText = yDoc.getText('content')
// Set up your editor binding here
// e.g., QuillBinding, TipTapExtension, etc.
return () => {
// Clean up binding
}
}, [yDoc, synced])
if (!synced) return <div>Syncing document...</div>
return (
<div>
{access === 'read' && <div className="read-only-banner">Read only</div>}
<div id="editor-container" />
</div>
)
}
Pattern 3: Fetching Data with useApi
import { useApi } from '@cloudillo/react'
import { useEffect, useState } from 'react'
function Profile({ idTag }) {
const { api } = useApi()
const [profile, setProfile] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!api) return
api.profiles.get(idTag)
.then(setProfile)
.catch(setError)
}, [api, idTag])
if (!api) return <div>No API client</div>
if (error) return <div>Error: {error.message}</div>
if (!profile) return <div>Loading...</div>
return (
<div>
<h1>{profile.name}</h1>
<p>{profile.idTag}</p>
</div>
)
}
Pattern 4: Creating Actions
import { useApi } from '@cloudillo/react'
import { useState } from 'react'
function CreatePost() {
const { api, authenticated } = useApi()
const [text, setText] = useState('')
const [posting, setPosting] = useState(false)
if (!authenticated) return <div>Please log in to post</div>
const handleSubmit = async (e) => {
e.preventDefault()
if (!api || !text.trim()) return
setPosting(true)
try {
await api.actions.create({
type: 'POST',
content: { text }
})
setText('')
} catch (error) {
console.error('Failed to create post:', error)
} finally {
setPosting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What's on your mind?"
/>
<button type="submit" disabled={posting || !text.trim()}>
{posting ? 'Posting...' : 'Post'}
</button>
</form>
)
}
Pattern 5: Role-Based Rendering
import { useAuth } from '@cloudillo/react'
function AdminPanel() {
const [auth] = useAuth()
if (!auth?.roles?.includes('admin')) {
return <div>Access denied. Admin role required.</div>
}
return (
<div>
<h1>Admin Panel</h1>
{/* Admin-only features */}
</div>
)
}
Pattern 6: File Upload
import { useApi } from '@cloudillo/react'
import { useState } from 'react'
function ImageUpload() {
const { api } = useApi()
const [uploading, setUploading] = useState(false)
const [result, setResult] = useState(null)
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
if (!file || !api) return
setUploading(true)
try {
// Upload the file
const uploaded = await api.files.uploadBlob(
'gallery', // preset
file.name, // fileName
file, // file data
file.type // contentType
)
setResult(uploaded)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
return (
<div>
<input type="file" accept="image/*" onChange={handleFileChange} />
{uploading && <div>Uploading...</div>}
{result && <div>Uploaded: {result.fileId}</div>}
</div>
)
}
Pattern 7: Real-Time Updates with RTDB
import { useApi, useAuth } from '@cloudillo/react'
import { useEffect, useState } from 'react'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
function TodoList({ dbFileId }) {
const [auth] = useAuth()
const [todos, setTodos] = useState([])
useEffect(() => {
if (!auth?.token || !auth?.idTag) return
const rtdb = new RtdbClient({
dbId: dbFileId,
auth: { getToken: () => auth.token },
serverUrl: getRtdbUrl(auth.idTag, dbFileId, auth.token)
})
const todosRef = rtdb.collection('todos')
// Subscribe to real-time updates
const unsubscribe = todosRef.onSnapshot((snapshot) => {
setTodos(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })))
})
// Cleanup on unmount
return () => {
unsubscribe()
rtdb.disconnect()
}
}, [auth?.token, auth?.idTag, dbFileId])
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
TypeScript Support
All hooks are fully typed:
import type { AuthState, ApiHook } from '@cloudillo/react'
import { useAuth, useApi } from '@cloudillo/react'
function MyComponent() {
const [auth, setAuth] = useAuth()
const { api, authenticated, setIdTag }: ApiHook = useApi()
// TypeScript knows the types
auth?.idTag // string | undefined
auth?.tnId // number | undefined
auth?.roles // string[] | undefined
api?.profiles.getOwn() // Returns Promise<ProfileKeys>
}
See Also
React Components Reference
Complete reference for all 85+ components, 12+ hooks, and utility functions exported by @cloudillo/react.
Layout Components
Container
Centered max-width container for page content.
import { Container } from '@cloudillo/react'
<Container>
<h1>Page Content</h1>
</Container>
HBox, VBox, Group
Flexbox layout primitives.
import { HBox, VBox, Group } from '@cloudillo/react'
// Horizontal layout
<HBox gap="md">
<Button>Left</Button>
<Button>Right</Button>
</HBox>
// Vertical layout
<VBox gap="sm">
<Input label="Name" />
<Input label="Email" />
</VBox>
// Grouped items with spacing
<Group>
<Tag>React</Tag>
<Tag>TypeScript</Tag>
</Group>
Props (shared):
gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' - Spacing between children
align?: 'start' | 'center' | 'end' | 'stretch' - Alignment
justify?: 'start' | 'center' | 'end' | 'between' | 'around' - Justification
Panel
Bordered container with optional header.
import { Panel } from '@cloudillo/react'
<Panel title="Settings">
<p>Panel content here</p>
</Panel>
Card
Elevated card container.
import { Card } from '@cloudillo/react'
<Card>
<h3>Card Title</h3>
<p>Card content</p>
</Card>
Fcd (Filter-Content-Details)
Three-column responsive layout pattern for list views.
import { Fcd, FcdContainer, FcdFilter, FcdContent, FcdDetails } from '@cloudillo/react'
<FcdContainer>
<FcdFilter>
<FilterBar />
</FcdFilter>
<FcdContent>
<ItemList />
</FcdContent>
<FcdDetails>
<ItemDetails />
</FcdDetails>
</FcdContainer>
Navigation Components
Collapsible sidebar with mobile support.
import {
useSidebar,
Sidebar,
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarNav,
SidebarSection,
SidebarToggle,
SidebarBackdrop,
SidebarResizeHandle
} from '@cloudillo/react'
function Layout() {
const sidebar = useSidebar({ defaultOpen: true })
return (
<div>
<SidebarBackdrop {...sidebar} />
<Sidebar {...sidebar}>
<SidebarHeader>
<Logo />
</SidebarHeader>
<SidebarContent>
<SidebarNav>
<SidebarSection title="Main">
<NavItem to="/home">Home</NavItem>
<NavItem to="/files">Files</NavItem>
</SidebarSection>
</SidebarNav>
</SidebarContent>
<SidebarFooter>
<ProfileCard />
</SidebarFooter>
<SidebarResizeHandle />
</Sidebar>
<SidebarToggle {...sidebar} />
</div>
)
}
useSidebar options:
interface UseSidebarOptions {
defaultOpen?: boolean
defaultWidth?: number
minWidth?: number
maxWidth?: number
breakpoint?: number // Mobile breakpoint
}
Additional components:
SidebarBackdrop - Mobile overlay backdrop that closes sidebar when tapped
SidebarResizeHandle - Draggable handle for resizing sidebar width
SidebarContext / useSidebarContext - Context provider and hook for nested components
Nav, NavGroup, NavItem, NavLink
Navigation menu components.
import { Nav, NavGroup, NavItem, NavLink } from '@cloudillo/react'
<Nav>
<NavGroup title="Main">
<NavItem icon={<HomeIcon />}>
<NavLink to="/home">Home</NavLink>
</NavItem>
<NavItem icon={<FilesIcon />}>
<NavLink to="/files">Files</NavLink>
</NavItem>
</NavGroup>
</Nav>
Tabs, Tab
Tabbed interface.
import { Tabs, Tab } from '@cloudillo/react'
<Tabs defaultValue="tab1">
<Tab value="tab1" label="General">
<GeneralSettings />
</Tab>
<Tab value="tab2" label="Advanced">
<AdvancedSettings />
</Tab>
</Tabs>
Context: TabsContext is available for building custom tab implementations.
Text input with label and validation.
import { Input } from '@cloudillo/react'
<Input
label="Email"
type="email"
placeholder="you@example.com"
error="Invalid email address"
/>
Props:
label?: string - Input label
error?: string - Error message
hint?: string - Helper text
size?: 'sm' | 'md' | 'lg' - Input size
- All standard
<input> props
TextArea
Multi-line text input.
import { TextArea } from '@cloudillo/react'
<TextArea
label="Description"
rows={4}
placeholder="Enter description..."
/>
Select
Custom dropdown select.
import { Select } from '@cloudillo/react'
<Select
label="Country"
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'de', label: 'Germany' }
]}
onChange={(value) => console.log(value)}
/>
NativeSelect
Native browser select element.
import { NativeSelect } from '@cloudillo/react'
<NativeSelect label="Priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</NativeSelect>
Numeric input with increment/decrement buttons.
import { NumberInput } from '@cloudillo/react'
<NumberInput
label="Quantity"
min={0}
max={100}
step={1}
value={5}
onChange={(value) => console.log(value)}
/>
Color picker input.
import { ColorInput } from '@cloudillo/react'
<ColorInput
label="Theme Color"
value="#3b82f6"
onChange={(color) => console.log(color)}
/>
Toggle
On/off toggle switch.
import { Toggle } from '@cloudillo/react'
<Toggle
label="Enable notifications"
checked={enabled}
onChange={setEnabled}
/>
Fieldset
Group related form fields.
import { Fieldset } from '@cloudillo/react'
<Fieldset legend="Contact Information">
<Input label="Phone" />
<Input label="Address" />
</Fieldset>
Group input with addons (prefix/suffix).
import { InputGroup } from '@cloudillo/react'
<InputGroup>
<InputGroup.Addon>https://</InputGroup.Addon>
<Input placeholder="example.com" />
</InputGroup>
Multi-value tag input with autocomplete.
import { TagInput } from '@cloudillo/react'
<TagInput
label="Tags"
value={['react', 'typescript']}
onChange={setTags}
suggestions={['react', 'vue', 'angular', 'typescript']}
placeholder="Add tags..."
/>
Inline editable text field.
import { InlineEditForm } from '@cloudillo/react'
<InlineEditForm
value={title}
onSave={(newValue) => updateTitle(newValue)}
placeholder="Click to edit..."
/>
Primary button component.
import { Button } from '@cloudillo/react'
<Button variant="primary" onClick={handleClick}>
Save Changes
</Button>
<Button variant="secondary" size="sm">
Cancel
</Button>
<Button variant="danger" loading>
Deleting...
</Button>
Props:
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'xs' | 'sm' | 'md' | 'lg'
loading?: boolean - Shows spinner
disabled?: boolean
icon?: ReactNode - Left icon
iconRight?: ReactNode - Right icon
Button styled as a link.
import { LinkButton } from '@cloudillo/react'
<LinkButton to="/settings">Go to Settings</LinkButton>
Dialog & Overlay Components
Dialog
Modal dialog with backdrop.
import { Dialog, useDialog } from '@cloudillo/react'
function MyComponent() {
const dialog = useDialog()
return (
<>
<Button onClick={dialog.open}>Open Dialog</Button>
<Dialog {...dialog} title="Confirm Action">
<p>Are you sure you want to proceed?</p>
<HBox gap="sm">
<Button variant="secondary" onClick={dialog.close}>Cancel</Button>
<Button variant="primary" onClick={handleConfirm}>Confirm</Button>
</HBox>
</Dialog>
</>
)
}
useDialog returns:
interface UseDialogReturn {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
DialogContainer
Wrapper component for rendering dialogs at the root level.
import { DialogContainer } from '@cloudillo/react'
function App() {
return (
<>
<MainContent />
<DialogContainer />
</>
)
}
Modal
Low-level modal wrapper.
import { Modal } from '@cloudillo/react'
<Modal isOpen={isOpen} onClose={handleClose}>
<div className="modal-content">
Custom modal content
</div>
</Modal>
BottomSheet
Mobile-friendly bottom sheet.
import { BottomSheet } from '@cloudillo/react'
<BottomSheet
isOpen={isOpen}
onClose={handleClose}
snapPoints={['50%', '90%']}
>
<div>Sheet content</div>
</BottomSheet>
Dropdown
Dropdown menu container.
import { Dropdown } from '@cloudillo/react'
<Dropdown
trigger={<Button>Options</Button>}
align="end"
>
<MenuItem onClick={handleEdit}>Edit</MenuItem>
<MenuItem onClick={handleDelete}>Delete</MenuItem>
</Dropdown>
Popper
Floating positioned element (tooltips, popovers).
import { Popper } from '@cloudillo/react'
<Popper
trigger={<Button>Hover me</Button>}
placement="top"
>
<div>Tooltip content</div>
</Popper>
QRCodeDialog
Dialog showing a QR code.
import { QRCodeDialog } from '@cloudillo/react'
<QRCodeDialog
isOpen={isOpen}
onClose={handleClose}
value="https://cloudillo.net/share/abc123"
title="Share Link"
/>
Context menu and dropdown menu items.
import { Menu, MenuItem, MenuDivider, MenuHeader } from '@cloudillo/react'
<Menu>
<MenuHeader>Actions</MenuHeader>
<MenuItem icon={<EditIcon />} onClick={handleEdit}>
Edit
</MenuItem>
<MenuItem icon={<CopyIcon />} onClick={handleCopy}>
Copy
</MenuItem>
<MenuDivider />
<MenuItem icon={<TrashIcon />} variant="danger" onClick={handleDelete}>
Delete
</MenuItem>
</Menu>
ActionSheet
Mobile action sheet (bottom menu).
import { ActionSheet, ActionSheetItem, ActionSheetDivider } from '@cloudillo/react'
<ActionSheet isOpen={isOpen} onClose={handleClose}>
<ActionSheetItem onClick={handleShare}>Share</ActionSheetItem>
<ActionSheetItem onClick={handleCopy}>Copy Link</ActionSheetItem>
<ActionSheetDivider />
<ActionSheetItem variant="danger" onClick={handleDelete}>
Delete
</ActionSheetItem>
</ActionSheet>
Feedback Components
Toast
Toast notification system.
import { useToast, ToastContainer } from '@cloudillo/react'
// In your app root
function App() {
return (
<>
<ToastContainer />
<MainContent />
</>
)
}
// In any component
function MyComponent() {
const toast = useToast()
const handleSave = async () => {
try {
await saveData()
toast.success('Saved successfully!')
} catch (err) {
toast.error('Failed to save')
}
}
return <Button onClick={handleSave}>Save</Button>
}
useToast methods:
toast.success(message, options?) - Success toast
toast.error(message, options?) - Error toast
toast.warning(message, options?) - Warning toast
toast.info(message, options?) - Info toast
toast.custom(content, options?) - Custom toast
Toast sub-components:
For building custom toast layouts:
import {
Toast,
ToastIcon,
ToastContent,
ToastTitle,
ToastMessage,
ToastActions,
ToastClose,
ToastProgress
} from '@cloudillo/react'
// Custom toast layout
<Toast variant="success">
<ToastIcon />
<ToastContent>
<ToastTitle>Success!</ToastTitle>
<ToastMessage>Your changes have been saved.</ToastMessage>
</ToastContent>
<ToastActions>
<Button size="sm">Undo</Button>
</ToastActions>
<ToastClose />
<ToastProgress />
</Toast>
Context: ToastContext and useToastContext are available for building custom toast providers.
LoadingSpinner
Spinning loader indicator.
import { LoadingSpinner } from '@cloudillo/react'
<LoadingSpinner size="md" />
{isLoading && <LoadingSpinner />}
Skeleton, SkeletonText, SkeletonCard, SkeletonList
Loading skeleton placeholders.
import { Skeleton, SkeletonText, SkeletonCard, SkeletonList } from '@cloudillo/react'
// Basic skeleton
<Skeleton width={200} height={20} />
// Text skeleton
<SkeletonText lines={3} />
// Card skeleton
<SkeletonCard />
// List skeleton
<SkeletonList count={5} />
Progress
Progress bar.
import { Progress } from '@cloudillo/react'
<Progress value={75} max={100} />
<Progress value={uploadProgress} showLabel />
EmptyState
Empty state placeholder.
import { EmptyState } from '@cloudillo/react'
<EmptyState
icon={<FilesIcon />}
title="No files yet"
description="Upload your first file to get started"
action={<Button>Upload File</Button>}
/>
Profile Components
Avatar, AvatarStatus, AvatarBadge, AvatarGroup
User avatar components.
import { Avatar, AvatarStatus, AvatarBadge, AvatarGroup } from '@cloudillo/react'
// Basic avatar
<Avatar src={profilePic} name="Alice" size="md" />
// With status indicator
<Avatar src={profilePic}>
<AvatarStatus status="online" />
</Avatar>
// With badge
<Avatar src={profilePic}>
<AvatarBadge>3</AvatarBadge>
</Avatar>
// Group of avatars
<AvatarGroup max={3}>
<Avatar src={user1.pic} name={user1.name} />
<Avatar src={user2.pic} name={user2.name} />
<Avatar src={user3.pic} name={user3.name} />
<Avatar src={user4.pic} name={user4.name} />
</AvatarGroup>
ProfilePicture, UnknownProfilePicture
Cloudillo profile picture components.
import { ProfilePicture, UnknownProfilePicture } from '@cloudillo/react'
<ProfilePicture idTag="alice.cloudillo.net" size="lg" />
<UnknownProfilePicture size="md" />
IdentityTag
Display identity tag with icon.
import { IdentityTag } from '@cloudillo/react'
<IdentityTag idTag="alice.cloudillo.net" />
ProfileCard
Profile card with picture, name, and actions.
import { ProfileCard } from '@cloudillo/react'
<ProfileCard
profile={profile}
onConnect={handleConnect}
onFollow={handleFollow}
/>
ProfileAudienceCard
Profile card optimized for audience selection.
import { ProfileAudienceCard } from '@cloudillo/react'
<ProfileAudienceCard
profile={profile}
selected={isSelected}
onSelect={handleSelect}
/>
EditProfileList
Editable list of profiles (for managing connections, etc.).
import { EditProfileList } from '@cloudillo/react'
<EditProfileList
profiles={connections}
onRemove={handleRemove}
emptyMessage="No connections yet"
/>
ProfileSelect
Profile selection input with search.
import { ProfileSelect } from '@cloudillo/react'
<ProfileSelect
value={selectedProfile}
onChange={setSelectedProfile}
placeholder="Search profiles..."
/>
Data Display Components
TreeView, TreeItem
Hierarchical tree view.
import { TreeView, TreeItem } from '@cloudillo/react'
<TreeView>
<TreeItem label="Documents" icon={<FolderIcon />}>
<TreeItem label="Report.pdf" icon={<FileIcon />} />
<TreeItem label="Notes.txt" icon={<FileIcon />} />
</TreeItem>
<TreeItem label="Images" icon={<FolderIcon />}>
<TreeItem label="Photo.jpg" icon={<ImageIcon />} />
</TreeItem>
</TreeView>
Accordion, AccordionItem
Collapsible accordion sections.
import { Accordion, AccordionItem } from '@cloudillo/react'
<Accordion>
<AccordionItem title="General Settings">
<GeneralSettings />
</AccordionItem>
<AccordionItem title="Advanced Settings">
<AdvancedSettings />
</AccordionItem>
</Accordion>
PropertyPanel, PropertySection, PropertyField
Property inspector panel (like IDE properties).
import { PropertyPanel, PropertySection, PropertyField } from '@cloudillo/react'
<PropertyPanel>
<PropertySection title="Appearance">
<PropertyField label="Width">
<NumberInput value={width} onChange={setWidth} />
</PropertyField>
<PropertyField label="Color">
<ColorInput value={color} onChange={setColor} />
</PropertyField>
</PropertySection>
</PropertyPanel>
Formatted time display (relative or absolute).
import { TimeFormat } from '@cloudillo/react'
<TimeFormat value={createdAt} />
// Renders: "2 hours ago" or "Jan 15, 2025"
Badge
Status badge.
import { Badge } from '@cloudillo/react'
<Badge variant="success">Active</Badge>
<Badge variant="warning">Pending</Badge>
<Badge variant="danger">Error</Badge>
Tag, TagList
Tags and tag lists.
import { Tag, TagList } from '@cloudillo/react'
<Tag>React</Tag>
<TagList
tags={['react', 'typescript', 'cloudillo']}
onRemove={handleRemoveTag}
/>
FilterBar
Filter bar with search and filters.
import {
FilterBar,
FilterBarSearch,
FilterBarItem,
FilterBarSection,
FilterBarDivider
} from '@cloudillo/react'
<FilterBar>
<FilterBarSearch
value={search}
onChange={setSearch}
placeholder="Search files..."
/>
<FilterBarDivider />
<FilterBarSection>
<FilterBarItem
label="Type"
value={typeFilter}
onChange={setTypeFilter}
options={typeOptions}
/>
<FilterBarItem
label="Date"
value={dateFilter}
onChange={setDateFilter}
options={dateOptions}
/>
</FilterBarSection>
</FilterBar>
Alternative export: FilterBarComponent is also exported for cases where you need to avoid naming conflicts.
Action toolbar.
import { Toolbar, ToolbarGroup, ToolbarDivider, ToolbarSpacer } from '@cloudillo/react'
<Toolbar>
<ToolbarGroup>
<Button icon={<BoldIcon />} />
<Button icon={<ItalicIcon />} />
<Button icon={<UnderlineIcon />} />
</ToolbarGroup>
<ToolbarDivider />
<ToolbarGroup>
<Button icon={<AlignLeftIcon />} />
<Button icon={<AlignCenterIcon />} />
</ToolbarGroup>
<ToolbarSpacer />
<Button>Save</Button>
</Toolbar>
LoadMoreTrigger
Intersection observer trigger for infinite scroll.
import { LoadMoreTrigger } from '@cloudillo/react'
import { useInfiniteScroll } from '@cloudillo/react'
function FileList() {
const { items, isLoading, sentinelRef } = useInfiniteScroll({
fetchPage: async (cursor, limit) => {
const result = await api.files.list({ cursor, limit })
return {
items: result.data,
nextCursor: result.cursorPagination?.nextCursor ?? null,
hasMore: result.cursorPagination?.hasMore ?? false
}
}
})
return (
<div>
{items.map(file => <FileCard key={file.fileId} file={file} />)}
<LoadMoreTrigger ref={sentinelRef} isLoading={isLoading} />
</div>
)
}
Utility Components
FormattedText
Render formatted text with markdown support.
import { FormattedText } from '@cloudillo/react'
<FormattedText content="**Bold** and *italic* text" />
FontPicker
Font selection component. Works with the @cloudillo/fonts library for font metadata and pairing suggestions.
import { FontPicker } from '@cloudillo/react'
import { getSuggestedBodyFonts } from '@cloudillo/fonts'
<FontPicker
value={fontFamily}
onChange={setFontFamily}
/>
// Use with font pairings
const suggestedBodies = getSuggestedBodyFonts(headingFont)
See @cloudillo/fonts for available fonts and pairing APIs.
ZoomableImage
Image component with pinch-to-zoom and pan support.
import { ZoomableImage } from '@cloudillo/react'
<ZoomableImage src={imageUrl} alt="Zoomable photo" />
DocumentEmbed, DocumentEmbedIframe, SvgDocumentEmbed
Components for embedding documents within other documents.
import { DocumentEmbedIframe, SvgDocumentEmbed, useDocumentEmbed } from '@cloudillo/react'
// Iframe-based embed (for HTML/interactive documents)
<DocumentEmbedIframe
fileId={targetFileId}
contentType="cloudillo/quillo"
sourceFileId={currentFileId}
/>
// SVG-based embed (for embedding within SVG canvases)
<SvgDocumentEmbed
fileId={targetFileId}
contentType="cloudillo/quillo"
sourceFileId={currentFileId}
width={400}
height={300}
/>
The useDocumentEmbed hook manages the embed lifecycle (requesting embed URLs from the shell, tracking loading state).
Utility Hooks
useMergedRefs
Combine multiple refs into one.
import { useMergedRefs } from '@cloudillo/react'
function MyComponent({ forwardedRef }) {
const localRef = useRef()
const mergedRef = useMergedRefs(localRef, forwardedRef)
return <div ref={mergedRef} />
}
useBodyScrollLock
Lock body scroll (for modals).
import { useBodyScrollLock } from '@cloudillo/react'
function Modal({ isOpen }) {
useBodyScrollLock(isOpen)
return isOpen ? <div className="modal">...</div> : null
}
useEscapeKey
Handle Escape key press.
import { useEscapeKey } from '@cloudillo/react'
function Modal({ onClose }) {
useEscapeKey(onClose)
return <div className="modal">...</div>
}
useOutsideClick
Detect clicks outside an element.
import { useOutsideClick } from '@cloudillo/react'
function Dropdown({ onClose }) {
const ref = useRef()
useOutsideClick(ref, onClose)
return <div ref={ref} className="dropdown">...</div>
}
Responsive media query hook.
import { useMediaQuery } from '@cloudillo/react'
function MyComponent() {
const isMobile = useMediaQuery('(max-width: 768px)')
return isMobile ? <MobileView /> : <DesktopView />
}
useIsMobile
Shorthand for mobile detection.
import { useIsMobile } from '@cloudillo/react'
function MyComponent() {
const isMobile = useIsMobile()
return isMobile ? <MobileNav /> : <DesktopNav />
}
usePrefersReducedMotion
Accessibility: detect reduced motion preference.
import { usePrefersReducedMotion } from '@cloudillo/react'
function AnimatedComponent() {
const prefersReducedMotion = usePrefersReducedMotion()
return (
<div className={prefersReducedMotion ? 'no-animation' : 'animated'}>
...
</div>
)
}
useDebouncedValue
Debounce a value that changes rapidly (e.g., search input).
import { useDebouncedValue } from '@cloudillo/react'
function SearchField() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebouncedValue(query, 300)
// debouncedQuery updates 300ms after the last query change
}
useDocumentEmbed
Manage state for embedding documents (requesting embed URLs from the shell).
import { useDocumentEmbed } from '@cloudillo/react'
function EmbedManager({ fileId, contentType }) {
const { embedUrl, loading, error } = useDocumentEmbed({
targetFileId: fileId,
targetContentType: contentType,
sourceFileId: currentFileId
})
}
See Also
@cloudillo/types
Overview
TypeScript type definitions for Cloudillo with runtime validation using @symbion/runtype.
Installation
pnpm add @cloudillo/types
Core Types
Profile
User or community profile information.
import type { Profile } from '@cloudillo/types'
const profile: Profile = {
idTag: 'alice@example.com',
name: 'Alice Johnson',
profilePic: '/file/b1~abc123'
}
Fields:
idTag: string - Unique identity (DNS-based)
name?: string - Display name
type?: 'person' | 'community' - Profile type
profilePic?: string - Profile picture URL
status?: ProfileStatus - Profile status
connected?: ProfileConnectionStatus - Connection status
following?: boolean - Whether the current user follows this profile
roles?: string[] - Community roles (e.g., ['leader'], ['moderator'])
Action
Represents a social action or activity.
import type { Action } from '@cloudillo/types'
const action: Action = {
actionId: 'act_123',
type: 'POST',
issuerTag: 'alice@example.com',
content: {
text: 'Hello, world!',
title: 'My First Post'
},
createdAt: 1735000000,
status: 'A'
}
Fields:
actionId: string - Unique action identifier
type: ActionType - Type of action (POST, CMNT, REACT, etc.)
issuerTag: string - Who created the action
content?: unknown - Action-specific content
createdAt: number - Unix timestamp (seconds)
status?: ActionStatus - P/A/D/C/N/R/S
subType?: string - Action subtype/category
parentId?: string - Parent action (for threads)
rootId?: string - Root action (for deep threads)
audienceTag?: string - Target audience
subject?: string - Subject/target (e.g., who to follow)
attachments?: string[] - File IDs
expiresAt?: number - Expiration timestamp
ActionType
Literal union of all action types.
import type { ActionType } from '@cloudillo/types'
const type: ActionType =
| 'CONN' // Connection request
| 'FLLW' // Follow user
| 'POST' // Create post
| 'REPOST' // Repost/share
| 'REACT' // Reaction
| 'CMNT' // Comment
| 'SHRE' // Share resource
| 'MSG' // Message
| 'FSHR' // File share
| 'PRINVT' // Profile invite
Additional Action Types
ACK (Acknowledgment) and RSTAT (Reaction Statistics) exist as action variants in the tagged union (tBaseAction) but are not part of the ActionType literal type. They are used internally for specific action handling.
ActionStatus
Action status enumeration.
import type { ActionStatus } from '@cloudillo/types'
const status: ActionStatus =
| 'P' // Pending (draft/unpublished)
| 'A' // Active (default - published/finalized)
| 'D' // Deleted (soft delete)
| 'C' // Created (pending approval, e.g., connection requests)
| 'N' // New (notification awaiting acknowledgment)
| 'R' // Draft (saved but not yet published)
| 'S' // Scheduled (draft with confirmed publish time)
ProfileStatus
Profile status codes.
import type { ProfileStatus } from '@cloudillo/types'
const status: ProfileStatus =
| 'A' // Active
| 'T' // Trusted
| 'B' // Blocked
| 'M' // Muted
| 'S' // Suspended
ProfileConnectionStatus
Connection status between profiles.
import type { ProfileConnectionStatus } from '@cloudillo/types'
// true = connected
// 'R' = request pending
// undefined = not connected
Community role hierarchy.
import type { CommunityRole } from '@cloudillo/types'
import { ROLE_LEVELS } from '@cloudillo/types'
type CommunityRole =
| 'public' // Level 0 - Anyone
| 'follower' // Level 1 - Following the community
| 'supporter' // Level 2 - Supporter/subscriber
| 'contributor' // Level 3 - Can create content
| 'moderator' // Level 4 - Can moderate
| 'leader' // Level 5 - Full admin access
// Use ROLE_LEVELS for permission checks
console.log(ROLE_LEVELS)
// { public: 0, follower: 1, supporter: 2, contributor: 3, moderator: 4, leader: 5 }
// Check if user has sufficient role
function hasPermission(userRole: CommunityRole, required: CommunityRole): boolean {
return ROLE_LEVELS[userRole] >= ROLE_LEVELS[required]
}
NewAction
Data for creating a new action.
import type { NewAction } from '@cloudillo/types'
const newAction: NewAction = {
type: 'POST',
content: {
text: 'Hello!',
title: 'Greeting'
},
attachments: ['file_123']
}
Fields:
type: string - Action type
subType?: string - Action subtype
parentId?: string - Parent action ID
rootId?: string - Root action ID
audienceTag?: string - Target audience
content?: unknown - Action content
attachments?: string[] - Attached file IDs
subject?: string - Subject/target
expiresAt?: number - Expiration time
visibility?: string - Visibility level ('P' = Public, 'C' = Connected, 'F' = Followers)
draft?: boolean - If true, save as draft (status 'R')
publishAt?: number - Unix timestamp for scheduled publishing
ActionView
Extended action with resolved references and statistics.
import type { ActionView } from '@cloudillo/types'
const actionView: ActionView = {
actionId: 'act_123',
type: 'POST',
issuer: {
idTag: 'alice@example.com',
name: 'Alice Johnson',
profilePic: '/file/b1~abc'
},
content: { text: 'Hello!' },
attachments: [
{ fileId: 'f1~abc', dim: [1920, 1080], localVariants: ['vis.tn', 'vis.sd'] }
],
createdAt: 1735000000,
stat: {
ownReaction: 'LOVE',
reactions: '10,3,1',
comments: 3,
commentsRead: 2
}
}
Additional Fields (beyond Action):
issuer: ProfileInfo - Resolved issuer profile
audience?: ProfileInfo - Resolved audience profile
attachments?: Array<{ fileId: string, dim?: [number, number] | null, localVariants?: string[] }> - Attachments with dimensions and available variants
status?: ActionStatus - Action status
stat?: object - Statistics:
ownReaction?: string - Current user’s reaction
reactions?: string - Encoded reaction counts
comments?: number - Number of comments
commentsRead?: number - Number of comments read by current user
visibility?: string - Visibility level
x?: unknown - Extensible metadata (e.g., x.role for subscriptions)
FileView
File metadata with owner information.
import type { FileView } from '@cloudillo/types'
const file: FileView = {
fileId: 'f1~abc123',
status: 'M',
contentType: 'image/png',
fileName: 'photo.png',
fileTp: 'BLOB',
createdAt: new Date('2025-01-01'),
tags: ['vacation', 'beach'],
owner: {
idTag: 'alice@example.com',
name: 'Alice'
}
}
Fields:
fileId: string - File identifier
status: 'P' | 'M' - Pending or Metadata-ready
contentType: string - MIME type
fileName: string - Original filename
fileTp?: string - File type (CRDT/RTDB/BLOB)
createdAt: string | Date - Creation time
tags?: string[] - Tags
owner?: Profile - Owner profile
preset?: string - Image preset configuration
Runtime Validation
All types include runtime validators using @symbion/runtype:
import { ProfileValidator, ActionValidator } from '@cloudillo/types'
// Validate data at runtime
const data = await api.profiles.getOwn()
if (ProfileValidator.validate(data)) {
// TypeScript knows data is a valid Profile
console.log(data.idTag)
} else {
console.error('Invalid profile data')
}
Type Guards
Use type guards to check types at runtime:
import { isAction, isProfile } from '@cloudillo/types'
function processData(data: unknown) {
if (isAction(data)) {
console.log('Action:', data.type)
} else if (isProfile(data)) {
console.log('Profile:', data.idTag)
}
}
Enum Constants
Action Types
import { ACTION_TYPES } from '@cloudillo/types'
console.log(ACTION_TYPES)
// ['CONN', 'FLLW', 'POST', 'REPOST', 'REACT', 'CMNT', 'SHRE', 'MSG', 'FSHR', 'PRINVT']
// Use in UI
{ACTION_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
))}
Action Statuses
import { ACTION_STATUSES } from '@cloudillo/types'
console.log(ACTION_STATUSES)
// ['P', 'A', 'D', 'C', 'N', 'R', 'S']
Action Type Variants
Typed action structures for type-safe action creation.
User Relationships
import type { ConnectAction, FollowAction } from '@cloudillo/types'
// Connection request
const connect: ConnectAction = {
type: 'CONN',
subject: 'bob.cloudillo.net', // Who to connect with
content: 'Would love to connect!' // Optional message
}
// Follow relationship
const follow: FollowAction = {
type: 'FLLW',
subject: 'news.cloudillo.net' // Who to follow
}
Content Actions
import type { PostAction, CommentAction, ReactAction } from '@cloudillo/types'
// Create a post
const post: PostAction = {
type: 'POST',
subType: 'IMG', // Optional: IMG, VID, etc.
content: 'Check out this photo!',
attachments: ['fileId123'], // Optional attachments
audience: 'friends.cloudillo.net' // Optional target audience
}
// Add a comment
const comment: CommentAction = {
type: 'CMNT',
parentId: 'actionId123', // Parent action to comment on
content: 'Great post!',
attachments: [] // Optional
}
// Add a reaction
const reaction: ReactAction = {
type: 'REACT',
parentId: 'actionId123', // Action to react to
content: 'LOVE' // Reaction type
}
Content Spreading
import type { AckAction, RepostAction, ShareAction } from '@cloudillo/types'
// Acknowledge content (accept to feed)
const ack: AckAction = {
type: 'ACK',
parentId: 'actionId123' // Action to acknowledge
}
// Repost content
const repost: RepostAction = {
type: 'REPOST',
parentId: 'actionId123', // Original action
content: 'Adding my thoughts...' // Optional comment
}
// Share directly to someone
const share: ShareAction = {
type: 'SHRE',
subject: 'actionId123', // What to share
audience: 'bob.cloudillo.net', // Who to share with
content: 'You might like this!' // Optional message
}
Messages
import type { MessageAction } from '@cloudillo/types'
// Direct message
const dm: MessageAction = {
type: 'MSG',
subType: 'TEXT',
content: 'Hello!',
audience: 'bob.cloudillo.net' // Recipient
}
// Group message (reply to conversation)
const groupMsg: MessageAction = {
type: 'MSG',
subType: 'TEXT',
content: 'Thanks everyone!',
parentId: 'conversationId123' // Conversation ID
}
File Sharing
import type { FileShareAction } from '@cloudillo/types'
// Share a file with read access
const fileShare: FileShareAction = {
type: 'FSHR',
subType: 'READ', // 'READ' or 'WRITE'
subject: 'fileId123', // File to share
audience: 'bob.cloudillo.net', // Who to share with
content: {
fileName: 'document.pdf',
contentType: 'application/pdf',
fileTp: 'BLOB'
}
}
Reaction Statistics
import type { ReactionStatAction } from '@cloudillo/types'
// Aggregated reaction stats (usually system-generated)
const stats: ReactionStatAction = {
type: 'RSTAT',
parentId: 'actionId123',
content: {
comment: 5, // Number of comments
reactions: [10, 3, 1] // Reaction counts by type
}
}
ProfileInfo
Embedded profile information in actions.
import type { ProfileInfo } from '@cloudillo/types'
const info: ProfileInfo = {
idTag: 'alice.cloudillo.net',
name: 'Alice', // Optional
profilePic: 'picId123', // Optional
type: 'person' // 'person' or 'community'
}
See Also
Overview
React components and hooks for interactive object manipulation in SVG canvas applications. Provides transform gizmos, rotation handles, pivot controls, and gradient pickers for building drawing and design tools.
Installation
pnpm add @cloudillo/canvas-tools
Peer Dependencies:
react >= 18
react-svg-canvas
Components
Complete transform control for SVG objects with rotation, scaling, and positioning.
import { TransformGizmo } from '@cloudillo/canvas-tools'
function CanvasEditor() {
const [bounds, setBounds] = useState({ x: 100, y: 100, width: 200, height: 150 })
const [rotation, setRotation] = useState(0)
return (
<svg width={800} height={600}>
<TransformGizmo
bounds={bounds}
rotation={rotation}
onBoundsChange={setBounds}
onRotationChange={setRotation}
showRotationHandle
showPivotHandle
/>
</svg>
)
}
Props (TransformGizmoProps):
bounds: Bounds - Object position and size { x, y, width, height }
rotation?: number - Rotation angle in degrees
pivot?: Point - Pivot point { x, y }
onBoundsChange?: (bounds: Bounds) => void - Bounds change callback
onRotationChange?: (angle: number) => void - Rotation change callback
onPivotChange?: (pivot: Point) => void - Pivot change callback
showRotationHandle?: boolean - Show rotation arc handle
showPivotHandle?: boolean - Show pivot point control
RotationHandle
Circular arc handle for rotating objects.
import { RotationHandle } from '@cloudillo/canvas-tools'
<RotationHandle
center={{ x: 200, y: 200 }}
radius={80}
currentAngle={rotation}
onRotate={(angle) => setRotation(angle)}
snapAngles={[0, 45, 90, 135, 180, 225, 270, 315]}
/>
Props (RotationHandleProps):
center: Point - Center point of rotation
radius: number - Arc radius
currentAngle: number - Current rotation angle
onRotate: (angle: number) => void - Rotation callback
snapAngles?: number[] - Angles to snap to (default: 45-degree increments)
snapZoneRatio?: number - Snap zone size ratio
PivotHandle
Draggable handle for setting the pivot/rotation center point.
import { PivotHandle } from '@cloudillo/canvas-tools'
<PivotHandle
position={{ x: 200, y: 200 }}
bounds={{ x: 100, y: 100, width: 200, height: 200 }}
onPivotChange={(point) => setPivot(point)}
snapToCenter
snapThreshold={10}
/>
Props (PivotHandleProps):
position: Point - Current pivot position
bounds: Bounds - Object bounds for snapping
onPivotChange: (point: Point) => void - Position change callback
snapToCenter?: boolean - Snap to center when close
snapThreshold?: number - Snap distance threshold
GradientPicker
Complete gradient editor with color stops, angle control, and presets.
import { GradientPicker } from '@cloudillo/canvas-tools'
import type { Gradient } from '@cloudillo/canvas-tools'
function GradientEditor() {
const [gradient, setGradient] = useState<Gradient>({
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: '#ff0000' },
{ offset: 1, color: '#0000ff' }
]
})
return (
<GradientPicker
value={gradient}
onChange={setGradient}
showPresets
showAngleControl
/>
)
}
Props (GradientPickerProps):
value: Gradient - Current gradient value
onChange: (gradient: Gradient) => void - Change callback
showPresets?: boolean - Show gradient preset grid
showAngleControl?: boolean - Show angle rotation control
showPositionControl?: boolean - Show radial gradient position control
GradientBar
Horizontal bar for editing gradient color stops.
import { GradientBar } from '@cloudillo/canvas-tools'
<GradientBar
stops={gradient.stops}
onStopsChange={(stops) => setGradient({ ...gradient, stops })}
selectedStop={selectedIndex}
onSelectStop={setSelectedIndex}
/>
GradientPresetGrid
Grid of predefined gradient presets.
import { GradientPresetGrid, GRADIENT_PRESETS } from '@cloudillo/canvas-tools'
<GradientPresetGrid
presets={GRADIENT_PRESETS}
onSelect={(preset) => setGradient(expandGradient(preset.gradient))}
category="warm"
/>
AngleControl
Circular control for setting gradient angle.
import { AngleControl, DEFAULT_ANGLE_PRESETS } from '@cloudillo/canvas-tools'
<AngleControl
value={angle}
onChange={setAngle}
presets={DEFAULT_ANGLE_PRESETS}
/>
PositionControl
XY position control for radial gradient centers.
import { PositionControl } from '@cloudillo/canvas-tools'
<PositionControl
value={{ x: 0.5, y: 0.5 }}
onChange={setPosition}
/>
Hooks
Hook for managing transform gizmo state and interactions.
import { useTransformGizmo } from '@cloudillo/canvas-tools'
function CanvasObject({ object, onUpdate }) {
const {
state,
handlers,
isDragging,
isRotating,
isResizing
} = useTransformGizmo({
bounds: object.bounds,
rotation: object.rotation,
pivot: object.pivot,
onBoundsChange: (bounds) => onUpdate({ ...object, bounds }),
onRotationChange: (rotation) => onUpdate({ ...object, rotation }),
onPivotChange: (pivot) => onUpdate({ ...object, pivot }),
snapAngles: [0, 45, 90, 135, 180, 225, 270, 315],
maintainAspectRatio: true
})
return (
<g {...handlers}>
{/* Object rendering */}
</g>
)
}
Options (TransformGizmoOptions):
bounds: Bounds - Initial bounds
rotation?: number - Initial rotation
pivot?: Point - Initial pivot
onBoundsChange?: (bounds: Bounds) => void
onRotationChange?: (angle: number) => void
onPivotChange?: (pivot: Point) => void
snapAngles?: number[] - Rotation snap angles
snapZoneRatio?: number - Snap sensitivity
maintainAspectRatio?: boolean - Lock aspect during resize
minWidth?: number - Minimum width constraint
minHeight?: number - Minimum height constraint
Returns (UseTransformGizmoReturn):
state: TransformGizmoState - Current transform state
handlers: TransformGizmoHandlers - Event handlers
isDragging: boolean - Move operation active
isRotating: boolean - Rotation operation active
isResizing: boolean - Resize operation active
Gradient Utilities
Creating Gradients
import {
DEFAULT_LINEAR_GRADIENT,
DEFAULT_RADIAL_GRADIENT,
expandGradient,
compactGradient
} from '@cloudillo/canvas-tools'
// Start with defaults
const linear = { ...DEFAULT_LINEAR_GRADIENT }
const radial = { ...DEFAULT_RADIAL_GRADIENT }
// Expand compact notation to full gradient
const full = expandGradient({ t: 'l', a: 90, s: [[0, '#f00'], [1, '#00f']] })
// Compact for storage
const compact = compactGradient(full)
Manipulating Stops
import {
addStop,
removeStop,
updateStop,
sortStops,
reverseStops
} from '@cloudillo/canvas-tools'
// Add a stop at 50%
const newStops = addStop(gradient.stops, 0.5, '#00ff00')
// Remove stop at index 1
const filtered = removeStop(gradient.stops, 1)
// Update stop color
const updated = updateStop(gradient.stops, 1, { color: '#ff00ff' })
// Sort by offset
const sorted = sortStops(stops)
// Reverse direction
const reversed = reverseStops(stops)
Converting to CSS/SVG
import {
gradientToCSS,
createLinearGradientDef,
createRadialGradientDef
} from '@cloudillo/canvas-tools'
// CSS background value
const css = gradientToCSS(gradient)
// "linear-gradient(90deg, #ff0000 0%, #0000ff 100%)"
// SVG gradient definition
const svgLinear = createLinearGradientDef('myGradient', gradient)
const svgRadial = createRadialGradientDef('myGradient', gradient)
Color Utilities
import {
interpolateColor,
getColorAtPosition
} from '@cloudillo/canvas-tools'
// Blend two colors
const mixed = interpolateColor('#ff0000', '#0000ff', 0.5)
// "#800080"
// Get color at position in gradient
const colorAt25 = getColorAtPosition(gradient.stops, 0.25)
Geometry Utilities
import {
getCanvasCoordinates,
getCanvasCoordinatesWithElement,
getSvgElement
} from '@cloudillo/canvas-tools'
function handleClick(event: MouseEvent) {
const svg = getSvgElement(event.target as Element)
const point = getCanvasCoordinates(event, svg)
console.log('Canvas position:', point.x, point.y)
}
Rotation Matrix
Pre-calculated trigonometry for performance-critical operations.
import {
createRotationMatrix,
rotatePointWithMatrix,
unrotatePointWithMatrix,
rotateDeltaWithMatrix,
unrotateDeltaWithMatrix
} from '@cloudillo/canvas-tools'
// Create matrix once
const matrix = createRotationMatrix(45) // 45 degrees
// Rotate points efficiently
const rotated = rotatePointWithMatrix({ x: 100, y: 0 }, matrix, center)
const original = unrotatePointWithMatrix(rotated, matrix, center)
// Rotate deltas (movement vectors)
const rotatedDelta = rotateDeltaWithMatrix({ x: 10, y: 0 }, matrix)
View Coordinates
import {
canvasToView,
viewToCanvas,
isPointInView,
boundsIntersectsView
} from '@cloudillo/canvas-tools'
// Convert between canvas and view coordinates
const viewPoint = canvasToView(canvasPoint, viewTransform)
const canvasPoint = viewToCanvas(viewPoint, viewTransform)
// Visibility checks
const visible = isPointInView(point, viewport)
const intersects = boundsIntersectsView(objectBounds, viewport)
Resize Calculations
import {
initResizeState,
calculateResizeBounds,
getAnchorForHandle,
getRotatedAnchorPosition
} from '@cloudillo/canvas-tools'
// Initialize resize operation
const resizeState = initResizeState(
bounds,
rotation,
'se', // corner handle
startMousePosition
)
// Calculate new bounds during drag
const newBounds = calculateResizeBounds(
resizeState,
currentMousePosition,
{ maintainAspectRatio: true }
)
Types
Core Types
import type {
Point,
Bounds,
ResizeHandle,
RotationState,
PivotState,
RotatedObjectBounds,
TransformedObject,
RotationMatrix,
ResizeState
} from '@cloudillo/canvas-tools'
type Point = { x: number; y: number }
type Bounds = {
x: number
y: number
width: number
height: number
}
type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
Gradient Types
import type {
GradientType,
GradientStop,
Gradient,
CompactGradient,
GradientPreset,
GradientPresetCategory
} from '@cloudillo/canvas-tools'
type GradientType = 'linear' | 'radial'
type GradientStop = {
offset: number // 0-1
color: string // hex color
}
type Gradient = {
type: GradientType
angle?: number // linear: degrees
cx?: number // radial: center x (0-1)
cy?: number // radial: center y (0-1)
stops: GradientStop[]
}
type CompactGradient = {
t: 'l' | 'r' // type
a?: number // angle
cx?: number
cy?: number
s: [number, string][] // stops as tuples
}
Constants
Rotation Defaults
import {
DEFAULT_SNAP_ANGLES,
DEFAULT_SNAP_ZONE_RATIO,
DEFAULT_PIVOT_SNAP_POINTS,
DEFAULT_PIVOT_SNAP_THRESHOLD
} from '@cloudillo/canvas-tools'
// [0, 45, 90, 135, 180, 225, 270, 315]
console.log(DEFAULT_SNAP_ANGLES)
// 0.1 (10% of arc)
console.log(DEFAULT_SNAP_ZONE_RATIO)
Arc Sizing
import {
ARC_RADIUS_MIN_VIEWPORT_RATIO,
ARC_RADIUS_MAX_VIEWPORT_RATIO,
DEFAULT_ARC_PADDING,
calculateArcRadius
} from '@cloudillo/canvas-tools'
const radius = calculateArcRadius({
objectBounds: bounds,
viewportSize: { width: 800, height: 600 },
padding: DEFAULT_ARC_PADDING
})
Angle Presets
import { DEFAULT_ANGLE_PRESETS } from '@cloudillo/canvas-tools'
// [0, 45, 90, 135, 180, 225, 270, 315]
console.log(DEFAULT_ANGLE_PRESETS)
Gradient Presets
import {
GRADIENT_PRESETS,
getPresetsByCategory,
getPresetById,
getCategories
} from '@cloudillo/canvas-tools'
// Get all categories
const categories = getCategories()
// ['warm', 'cool', 'vibrant', 'subtle', 'monochrome']
// Get presets in a category
const warmGradients = getPresetsByCategory('warm')
// Get specific preset
const sunset = getPresetById('sunset')
See Also
@cloudillo/fonts
Overview
The @cloudillo/fonts library provides a curated collection of Google Fonts metadata and pairing suggestions for Cloudillo applications. It enables font selection UIs, typography systems, and design tools.
Key Features:
- Curated font metadata for 22 Google Fonts
- Pre-defined font pairings (heading + body combinations)
- Helper functions for filtering and lookup
- Full TypeScript support
Installation
pnpm add @cloudillo/fonts
FONTS Constant
The FONTS array contains metadata for all available fonts.
import { FONTS } from '@cloudillo/fonts'
// List all fonts
FONTS.forEach(font => {
console.log(font.displayName, font.category, font.roles)
})
Each font entry includes:
family - CSS font-family value (e.g., 'Roboto')
displayName - Human-readable name
category - 'sans-serif' | 'serif' | 'display' | 'monospace'
roles - Suitable uses: 'heading' | 'body' | 'display' | 'mono'
weights - Available font weights
hasItalic - Whether italic variants exist
license - 'OFL' or 'Apache-2.0'
directory - Font directory name
getFontByFamily
Look up a font by its family name.
import { getFontByFamily } from '@cloudillo/fonts'
const roboto = getFontByFamily('Roboto')
// { family: 'Roboto', category: 'sans-serif', roles: ['body', 'heading'], ... }
const unknown = getFontByFamily('Unknown Font')
// undefined
getFontsByCategory
Filter fonts by category.
import { getFontsByCategory } from '@cloudillo/fonts'
const serifFonts = getFontsByCategory('serif')
// Returns: Playfair Display, Merriweather, Lora, Crimson Pro, Source Serif 4, DM Serif Display
const displayFonts = getFontsByCategory('display')
// Returns: Oswald, Bebas Neue, Abril Fatface, Permanent Marker
Available categories:
sans-serif - Clean, modern fonts (Roboto, Open Sans, Montserrat, etc.)
serif - Traditional fonts with serifs (Playfair Display, Merriweather, etc.)
display - Decorative fonts for headlines (Oswald, Bebas Neue, etc.)
monospace - Fixed-width fonts (JetBrains Mono)
getFontsByRole
Filter fonts by intended use.
import { getFontsByRole } from '@cloudillo/fonts'
const headingFonts = getFontsByRole('heading')
// Fonts suitable for headings: Roboto, Montserrat, Poppins, Playfair Display, etc.
const bodyFonts = getFontsByRole('body')
// Fonts suitable for body text: Roboto, Open Sans, Lato, Inter, etc.
Available roles:
heading - Suitable for titles and headings
body - Suitable for body text
display - Decorative, for large display text
mono - Monospace, for code
Font Pairings API
The library includes curated heading + body font combinations that work well together.
FONT_PAIRINGS Constant
import { FONT_PAIRINGS } from '@cloudillo/fonts'
FONT_PAIRINGS.forEach(pairing => {
console.log(`${pairing.name}: ${pairing.heading} + ${pairing.body}`)
})
Available pairings:
| ID |
Name |
Heading |
Body |
Description |
modern-professional |
Modern Professional |
Oswald |
Roboto |
Business presentations |
elegant-editorial |
Elegant Editorial |
Playfair Display |
Source Sans 3 |
Articles and long-form |
clean-modern |
Clean Modern |
Montserrat |
Open Sans |
Tech and startups |
readable-classic |
Readable Classic |
Merriweather |
Lato |
Blogs and docs |
contemporary-tech |
Contemporary Tech |
Poppins |
Inter |
Digital products |
literary-warm |
Literary Warm |
Lora |
Nunito Sans |
Classic with friendly body |
light-minimalist |
Light Minimalist |
Raleway |
Work Sans |
Minimal designs |
academic-formal |
Academic Formal |
Crimson Pro |
DM Sans |
Scholarly content |
bold-impact |
Bold Impact |
Bebas Neue |
Source Serif 4 |
Impactful headlines |
geometric-harmony |
Geometric Harmony |
DM Serif Display |
DM Sans |
Cohesive DM family |
getPairingById
Look up a specific pairing.
import { getPairingById } from '@cloudillo/fonts'
const pairing = getPairingById('modern-professional')
// { id: 'modern-professional', name: 'Modern Professional', heading: 'Oswald', body: 'Roboto', ... }
getPairingsForFont
Find pairings that use a specific font.
import { getPairingsForFont } from '@cloudillo/fonts'
const robotoPairings = getPairingsForFont('Roboto')
// Returns pairings where Roboto is used as heading or body
getSuggestedBodyFonts
Get body font suggestions for a heading font.
import { getSuggestedBodyFonts } from '@cloudillo/fonts'
const bodyOptions = getSuggestedBodyFonts('Oswald')
// ['Roboto']
const playfairBodies = getSuggestedBodyFonts('Playfair Display')
// ['Source Sans 3']
getSuggestedHeadingFonts
Get heading font suggestions for a body font.
import { getSuggestedHeadingFonts } from '@cloudillo/fonts'
const headingOptions = getSuggestedHeadingFonts('Inter')
// ['Poppins']
const latoHeadings = getSuggestedHeadingFonts('Lato')
// ['Merriweather']
TypeScript Types
import type {
FontCategory,
FontRole,
FontWeight,
FontMetadata,
FontPairing
} from '@cloudillo/fonts'
FontCategory
type FontCategory = 'sans-serif' | 'serif' | 'display' | 'monospace'
FontRole
type FontRole = 'heading' | 'body' | 'display' | 'mono'
FontWeight
interface FontWeight {
value: number // CSS font-weight value (400, 700, etc.)
label: string // Display name ('Regular', 'Bold', etc.)
italic?: boolean // Whether this is an italic variant
}
interface FontMetadata {
family: string // CSS font-family value
displayName: string // Human-readable name
category: FontCategory // Font category
roles: FontRole[] // Suitable roles
weights: FontWeight[] // Available weights
hasItalic: boolean // Has italic variants
license: 'OFL' | 'Apache-2.0'
directory: string // Font directory name
}
FontPairing
interface FontPairing {
id: string // Unique identifier
name: string // Human-readable name
heading: string // Heading font family
body: string // Body font family
description: string // Pairing description
}
Integration with FontPicker
The @cloudillo/fonts library works with the FontPicker component from @cloudillo/react.
import { FontPicker } from '@cloudillo/react'
import { FONTS, FONT_PAIRINGS, getSuggestedBodyFonts } from '@cloudillo/fonts'
function TypographySettings() {
const [headingFont, setHeadingFont] = useState('Montserrat')
const [bodyFont, setBodyFont] = useState('Open Sans')
// Get suggested body fonts when heading changes
const suggestedBodies = getSuggestedBodyFonts(headingFont)
return (
<div>
<FontPicker
label="Heading Font"
value={headingFont}
onChange={setHeadingFont}
/>
<FontPicker
label="Body Font"
value={bodyFont}
onChange={setBodyFont}
/>
{suggestedBodies.length > 0 && (
<p>Suggested body fonts: {suggestedBodies.join(', ')}</p>
)}
</div>
)
}
Available Fonts
Sans-Serif
| Font |
Roles |
Weights |
| Roboto |
body, heading |
400, 700 |
| Open Sans |
body |
400, 700 |
| Montserrat |
heading, body |
400, 700 |
| Lato |
body |
400, 700 |
| Poppins |
heading, body |
400, 700 |
| Inter |
body |
400, 700 |
| Nunito Sans |
body |
400, 700 |
| Work Sans |
body |
400, 700 |
| Raleway |
heading |
400, 700 |
| DM Sans |
body |
400, 700 |
| Source Sans 3 |
body |
400, 700 |
Serif
| Font |
Roles |
Weights |
| Playfair Display |
heading, display |
400, 700 |
| Merriweather |
heading, body |
400, 700 |
| Lora |
heading, body |
400, 700 |
| Crimson Pro |
heading, body |
400, 700 |
| Source Serif 4 |
body |
400, 700 |
| DM Serif Display |
heading, display |
400 |
Display
| Font |
Roles |
Weights |
| Oswald |
heading, display |
400, 700 |
| Bebas Neue |
heading, display |
400 |
| Abril Fatface |
display |
400 |
| Permanent Marker |
display |
400 |
Monospace
| Font |
Roles |
Weights |
| JetBrains Mono |
mono |
400, 700 |
See Also
Common Patterns
Idiomatic ways to combine Cloudillo APIs when building apps. Each pattern shows the minimal Cloudillo-specific code — wrap in your own components as needed. For full API details, see the linked reference pages.
Data loading
Cloudillo list APIs use cursor-based pagination. The useInfiniteScroll hook from @cloudillo/react manages cursor tracking, accumulates items across pages, and triggers loading via IntersectionObserver when a sentinel element becomes visible.
import { useInfiniteScroll } from '@cloudillo/react'
const { items, isLoading, hasMore, sentinelRef, prepend } = useInfiniteScroll({
fetchPage: async (cursor, limit) => {
const result = await api.actions.listPaginated({ type: 'POST', cursor, limit })
return {
items: result.data,
nextCursor: result.cursorPagination?.nextCursor ?? null,
hasMore: result.cursorPagination?.hasMore ?? false
}
},
pageSize: 20,
deps: [filterType] // resets when dependencies change
})
Attach sentinelRef to a <div> at the bottom of your list — it automatically triggers loadMore when scrolled into view. Use prepend() to insert real-time updates at the top without resetting the list.
See: @cloudillo/react | Actions API
Profile search
The profiles API supports text search. Combine with your own debounce logic to reduce API calls:
const results = await api.profiles.list({ q: searchQuery, limit: 10 })
See: Profiles API
Real-time collaboration
Collaborative editing (CRDT)
The useCloudilloEditor hook sets up a Yjs document with WebSocket sync. It returns a Y.Doc and a WebsocketProvider — bind these to any Yjs-compatible editor (Quill, ProseMirror, CodeMirror, BlockNote). Wait for synced before initializing the editor binding.
import { useCloudilloEditor } from '@cloudillo/react'
const { yDoc, provider, synced, error, ownerTag, fileId } = useCloudilloEditor('my-app')
// Once synced, bind to your editor:
const yText = yDoc.getText('content')
const binding = new QuillBinding(yText, quill, provider.awareness)
The error field captures connection errors (e.g., 440x codes from the CRDT server) so you can show appropriate UI.
See: CRDT (Collaborative Editing) | @cloudillo/react
Presence awareness
The provider from useCloudilloEditor includes a Yjs awareness instance. Subscribe to it to show who is currently viewing or editing:
provider.awareness.on('change', () => {
const states = provider.awareness.getStates()
// Each state contains { user: { name, profilePic, ... } }
// Filter out own clientID: provider.awareness.clientID
})
See: CRDT (Collaborative Editing)
Social features
Connection requests
Cloudillo connections are bidirectional. Send a request by creating a CONN action, and accept incoming requests with accept:
// Send a connection request
await api.actions.create({
type: 'CONN',
subject: profile.idTag,
content: 'Would love to connect!'
})
// Accept an incoming request
await api.actions.accept(actionId)
Check profile.connected for the current connection state:
true — connected
'R' — request pending
- absent — not connected
See: Actions API
Role-based access
Community roles follow a numeric hierarchy defined in ROLE_LEVELS. Use useAuth() to get the current user’s roles and compare:
import { ROLE_LEVELS, type CommunityRole } from '@cloudillo/types'
function hasRole(userRoles: string[] | undefined, required: CommunityRole): boolean {
if (!userRoles?.length) return false
const level = Math.max(...userRoles.map(r => ROLE_LEVELS[r as CommunityRole] ?? 0))
return level >= ROLE_LEVELS[required]
}
// Usage: hasRole(auth?.roles, 'moderator')
Role hierarchy: public < follower < supporter < contributor < moderator < leader.
See: @cloudillo/react | @cloudillo/types
File management
Uploading files
Use api.files.uploadBlob with a preset name. The preset determines what variants (thumbnail, standard definition) are automatically generated server-side:
const result = await api.files.uploadBlob(
'gallery', // preset: generates thumbnail + SD variants
file.name,
file,
file.type
)
// result: { fileId, variantId }
See: Files API
General tips
- Use
useToast() from @cloudillo/react for user feedback after API calls — see components reference
- Wrap route-level components in an error boundary for graceful failure handling — see error handling
- For reactions and toggles, use optimistic UI updates: save previous state, update immediately, rollback in
catch
- All list APIs support cursor pagination — prefer
useInfiniteScroll over manual fetch loops
- Check
api is not null (user is authenticated) before making API calls
- CRDT and RTDB serve different use cases: CRDT for rich document editing, RTDB for structured data collections — see RTDB and CRDT
See also
REST API
REST API Reference
Cloudillo provides a comprehensive REST API for building applications. All endpoints return JSON and use standard HTTP methods.
Base URL
https://your-cloudillo-server.com
For local development:
Authentication
Most endpoints require authentication via JWT tokens in the Authorization header:
Authorization: Bearer eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9...
See Authentication for details on obtaining and managing tokens.
All successful responses follow this format:
{
"data": <payload>,
"time": "2025-01-01T12:00:00Z",
"reqId": "req_abc123"
}
For list endpoints, cursor-based pagination is recommended:
{
"data": [...],
"cursorPagination": {
"nextCursor": "eyJzIjoiY3JlYXRlZCIsInYiOjE3MzUwMDAwMDAsImlkIjoiYTF-YWJjMTIzIn0",
"hasMore": true
},
"time": "2025-01-01T12:00:00Z"
}
Legacy offset-based pagination (deprecated):
{
"data": [...],
"pagination": {
"total": 150,
"offset": 0,
"limit": 20
},
"time": "2025-01-01T12:00:00Z"
}
Errors return this structure:
{
"error": {
"code": "E-AUTH-UNAUTH",
"message": "Unauthorized access",
"details": {...}
},
"time": 1735000000,
"reqId": "req_abc123"
}
See Error Handling for all error codes.
Common Query Parameters
Many list endpoints support these parameters:
limit - Maximum number of results (default: 20)
cursor - Opaque cursor for pagination (from previous response)
sort - Sort field (e.g., created, modified, name)
sortDir - Sort direction (asc or desc)
Endpoint Categories
User authentication and token management.
POST /api/auth/login - Login and get token
POST /api/auth/login-init - Combined login init (token + QR + WebAuthn)
POST /api/auth/logout - Logout
POST /api/auth/password - Change password
GET /api/auth/login-token - Refresh token
GET /api/auth/access-token - Get scoped token
GET /api/auth/proxy-token - Get federation token
POST /api/auth/qr-login/init - QR login init
GET /api/auth/qr-login/{session_id}/status - QR login status
GET /api/me - Get tenant profile (public)
GET /.well-known/cloudillo/id-tag - Resolve identity
User and community profiles.
POST /api/profiles/register - Register new user
POST /api/profiles/verify - Verify identity availability
GET /api/me - Get own profile
PATCH /api/me - Update own profile
PUT /api/me/image - Upload profile image
PUT /api/me/cover - Upload cover image
GET /api/profiles - List profiles
GET /api/profiles/:idTag - Get specific profile
PATCH /api/profiles/:idTag - Update relationship
PATCH /api/admin/profiles/:idTag - Admin update profile
Social features: posts, comments, reactions, connections.
GET /api/actions - List actions
POST /api/actions - Create action
GET /api/actions/{actionId} - Get action
PATCH /api/actions/{actionId} - Update draft action
DELETE /api/actions/{actionId} - Delete action
POST /api/actions/{actionId}/accept - Accept action
POST /api/actions/{actionId}/reject - Reject action
POST /api/actions/{actionId}/dismiss - Dismiss notification
POST /api/actions/{actionId}/publish - Publish draft
POST /api/actions/{actionId}/cancel - Cancel scheduled
POST /api/actions/{actionId}/stat - Update statistics
POST /api/actions/{actionId}/reaction - Add reaction
POST /api/inbox - Federation inbox (async)
POST /api/inbox/sync - Federation inbox (sync)
File upload, download, and management.
GET /api/files - List files
POST /api/files - Create file metadata (CRDT/RTDB)
POST /api/files/{preset}/{file_name} - Upload file (BLOB)
GET /api/files/{fileId} - Download file
GET /api/files/{fileId}/descriptor - Get file info
GET /api/files/{fileId}/metadata - Get file metadata
PATCH /api/files/{fileId} - Update file
DELETE /api/files/{fileId} - Delete file
POST /api/files/{fileId}/duplicate - Duplicate CRDT/RTDB file
POST /api/files/{fileId}/restore - Restore from trash
PUT /api/files/{fileId}/tag/{tag} - Add tag
DELETE /api/files/{fileId}/tag/{tag} - Remove tag
GET /api/files/variant/{variantId} - Get variant
App package management.
GET /api/apps - List available apps
POST /api/apps/install - Install app
GET /api/apps/installed - List installed apps
DELETE /api/apps/@{publisher}/{name} - Uninstall app
File sharing and access grants.
GET /api/shares - List shares by subject
GET /api/files/{fileId}/shares - List file shares
POST /api/files/{fileId}/shares - Create share
DELETE /api/files/{fileId}/shares/{shareId} - Delete share
User preferences and configuration.
GET /api/settings - List all settings
GET /api/settings/:name - Get setting
PUT /api/settings/:name - Update setting
Bookmarks and shortcuts.
GET /api/refs - List references
POST /api/refs - Create reference
GET /api/refs/:refId - Get reference
DELETE /api/refs/:refId - Delete reference
File and content tagging.
GET /api/tags - List tags
PUT /api/files/:fileId/tag/:tag - Add tag
DELETE /api/files/:fileId/tag/:tag - Remove tag
Trash management.
GET /api/files?parentId=__trash__ - List trashed files
POST /api/files/:fileId/restore - Restore from trash
DELETE /api/files/:fileId?permanent=true - Permanently delete
DELETE /api/trash - Empty trash
Community creation and management.
PUT /api/profiles/:idTag - Create community
POST /api/profiles/verify - Verify availability
System administration (requires admin role).
GET /api/admin/tenants - List tenants
POST /api/admin/tenants/{idTag}/password-reset - Send password reset
POST /api/admin/email/test - Test SMTP
PATCH /api/admin/profiles/{idTag} - Admin profile update
GET /api/admin/proxy-sites - List proxy sites
POST /api/admin/proxy-sites - Create proxy site
PATCH /api/admin/proxy-sites/{siteId} - Update proxy site
DELETE /api/admin/proxy-sites/{siteId} - Delete proxy site
POST /api/admin/proxy-sites/{siteId}/renew-cert - Renew certificate
POST /api/admin/invite-community - Invite community
Identity provider administration.
GET /api/idp/identities - List managed identities
POST /api/idp/identities - Create identity
GET /api/idp/identities/:idTag - Get identity
PATCH /api/idp/identities/:idTag - Update identity
PUT /api/idp/identities/:idTag/address - Update identity address (DNS)
DELETE /api/idp/identities/:idTag - Delete identity
GET /api/idp/api-keys - List API keys
POST /api/idp/api-keys - Create API key
DELETE /api/idp/api-keys/:keyId - Delete API key
Rate Limiting
API requests are rate-limited per tenant:
- Default: 1000 requests per minute
- Authenticated: 5000 requests per minute
- Admin: Unlimited
Rate limit headers:
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4950
X-RateLimit-Reset: 1735000000
CORS
CORS is enabled for all origins in development mode. In production, configure allowed origins in the server settings.
Timestamps
All response timestamps are in ISO 8601 format:
{
"createdAt": "2025-01-01T12:00:00Z"
}
Query parameter timestamps accept both ISO 8601 strings and Unix seconds:
GET /api/actions?createdAfter=2025-01-01T00:00:00Z
GET /api/actions?createdAfter=1735689600
Content Types
Request Content-Type
Most endpoints accept:
Content-Type: application/json
File uploads use:
Content-Type: multipart/form-data
Response Content-Type
All responses return:
Content-Type: application/json; charset=utf-8
Except file downloads which return the appropriate MIME type.
HTTP Methods
GET - Retrieve resources
POST - Create resources
PATCH - Partially update resources
PUT - Replace resources
DELETE - Delete resources
Idempotency
PUT, PATCH, and DELETE operations are idempotent. POST operations are not idempotent unless you provide an idempotencyKey:
{
"idempotencyKey": "unique-key-123",
"type": "POST",
"content": {...}
}
List endpoints use cursor-based pagination for stable results:
GET /api/actions?limit=20
Response includes pagination info:
{
"data": [...],
"cursorPagination": {
"nextCursor": "eyJzIjoiY3JlYXRlZCIsInYiOjE3MzUwMDAwMDAsImlkIjoiYTF-YWJjMTIzIn0",
"hasMore": true
},
"time": "2025-01-01T12:00:00Z"
}
To fetch the next page, use the cursor:
GET /api/actions?limit=20&cursor=eyJzIjoiY3JlYXRlZCIsInYiOjE3MzUwMDAwMDAsImlkIjoiYTF-YWJjMTIzIn0
Filtering
Many endpoints support filtering via query parameters. Each endpoint documents its available filters.
GET /api/actions?type=POST&status=A&createdAfter=2025-01-01T00:00:00Z
WebSocket Endpoints
For real-time features, use WebSocket connections:
WSS /ws/crdt/{doc_id} - Collaborative documents
WSS /ws/rtdb/{file_id} - Real-time database
WSS /ws/bus - Message bus
See WebSocket API for details.
Quick Start
Using @cloudillo/core
import * as cloudillo from '@cloudillo/core'
await cloudillo.init('my-app')
const api = cloudillo.createApiClient()
// Make requests
const profile = await api.profiles.getOwn()
const posts = await api.actions.list({ type: 'POST' })
Using fetch directly
const response = await fetch('/api/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const data = await response.json()
console.log(data.data) // Profile object
console.log(data.time) // Timestamp
console.log(data.reqId) // Request ID
Cursors are opaque base64-encoded strings containing:
- Sort field (
s)
- Sort value (
v)
- Last item ID (
id)
{
"s": "created",
"v": 1735000000,
"id": "a1~abc123"
}
Benefits:
- Stable: Results don’t shift when new items are added
- Efficient: No offset scanning in database
- Reliable: Works with large datasets
Use the SDK for easier pagination:
let cursor = undefined
while (true) {
const result = await api.actions.list({ type: 'POST', limit: 20, cursor })
// Process result.data
if (!result.cursorPagination?.hasMore) break
cursor = result.cursorPagination.nextCursor
}
Next Steps
Explore specific endpoint categories:
Subsections of REST API
Authentication API
Overview
User authentication and token management endpoints. For user registration, see Profiles API.
Endpoints
Login
Authenticate with email/password and receive an access token.
Authentication: Not required
Request:
{
"idTag": "alice@example.com",
"password": "secure-password"
}
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice Johnson",
"token": "eyJhbGc...",
"roles": ["user"],
"profilePic": "b1~abc123",
"settings": [["theme", "dark"], ["lang", "en"]],
"swEncryptionKey": "base64url-encoded-key"
},
"time": "2025-01-01T12:00:00Z"
}
| Field |
Type |
Description |
tnId |
number |
Tenant ID |
idTag |
string |
User identity tag |
name |
string |
Display name |
token |
string |
JWT access token |
roles |
string[] |
User roles (e.g., user, SADM) |
profilePic |
string |
Profile picture file ID |
settings |
[string, string][] |
User settings as key-value pairs |
swEncryptionKey |
string |
Service worker encryption key for push notifications (optional) |
Combined Login Init
POST /api/auth/login-init
Combined login initialization that returns all available authentication methods in a single request. If the user already has a valid token, returns the authenticated session directly.
Authentication: Optional
Response (unauthenticated):
{
"data": {
"status": "unauthenticated",
"qrLogin": {
"sessionId": "session_abc123",
"secret": "base64-secret"
},
"webAuthn": {
"challenge": "base64-encoded-challenge",
"rpId": "example.com",
"allowCredentials": [...]
}
},
"time": "2025-01-01T12:00:00Z"
}
Response (already authenticated):
{
"data": {
"status": "authenticated",
"login": {
"tnId": 12345,
"idTag": "alice@example.com",
"token": "eyJhbGc..."
}
},
"time": "2025-01-01T12:00:00Z"
}
Logout
Invalidate the current session.
Authentication: Required
Change Password
Change the authenticated user’s password.
Authentication: Required
Request:
{
"currentPassword": "current-password",
"newPassword": "new-secure-password"
}
Response:
{
"data": {
"success": true
},
"time": "2025-01-01T12:00:00Z"
}
Set Password
POST /api/auth/set-password
Set a new password using a reset token. Used during password recovery flow.
Authentication: Not required
Request:
{
"token": "reset-token-from-email",
"password": "new-secure-password"
}
Response:
{
"data": {
"success": true
},
"time": "2025-01-01T12:00:00Z"
}
Forgot Password
POST /api/auth/forgot-password
Request a password reset email.
Authentication: Not required
Request:
{
"idTag": "alice@example.com"
}
Response:
{
"data": {
"sent": true
},
"time": "2025-01-01T12:00:00Z"
}
Refresh Login Token
GET /api/auth/login-token
Refresh the authentication token before it expires.
Authentication: Not required (uses existing valid token)
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735086400
},
"time": "2025-01-01T12:00:00Z"
}
Get Access Token
GET /api/auth/access-token
Exchange credentials or tokens for a scoped access token. Supports multiple authentication methods.
Authentication: Not required
Query Parameters:
token - Existing token to exchange
refId - Reference ID for share links
apiKey - API key for programmatic access
scope - Requested scope (optional)
refresh - Set to true to refresh an existing token
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735086400,
"scope": "read:files"
},
"time": "2025-01-01T12:00:00Z"
}
Get Proxy Token
GET /api/auth/proxy-token
Get a proxy token for accessing remote resources via federation.
Authentication: Required
Query Parameters:
target - Target identity for federation
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735555555
},
"time": "2025-01-01T12:00:00Z"
}
QR Code Login
QR code login allows users to authenticate on a desktop browser by scanning a QR code with their mobile device.
Initialize QR Login
POST /api/auth/qr-login/init
Start a QR login session. The returned sessionId is encoded in the QR code, while the secret is kept by the initiating client for verification.
Authentication: Not required
Response:
{
"data": {
"sessionId": "session_abc123",
"secret": "base64-secret-key"
},
"time": "2025-01-01T12:00:00Z"
}
Poll QR Login Status
GET /api/auth/qr-login/{session_id}/status
Long-poll the QR login session for status changes. The request blocks until the status changes or the timeout expires.
Authentication: Not required
Path Parameters:
session_id - The session ID from the init response
Query Parameters:
timeout - Long-poll timeout in seconds (default: 15, max: 30)
Headers:
x-qr-secret - The secret from the init response (required for verification)
Response:
{
"data": {
"status": "approved",
"login": {
"tnId": 12345,
"idTag": "alice@example.com",
"token": "eyJhbGc...",
"name": "Alice Johnson"
}
},
"time": "2025-01-01T12:00:00Z"
}
Status Values:
| Status |
Description |
pending |
Waiting for mobile device to scan |
approved |
User approved on mobile - login field included |
denied |
User denied on mobile |
expired |
Session timed out |
Get QR Login Details
GET /api/auth/qr-login/{session_id}/details
Get the details of a QR login session. Called by the mobile device after scanning the QR code to display the login request to the user.
Authentication: Required
Path Parameters:
session_id - The session ID encoded in the QR code
Response:
{
"data": {
"sessionId": "session_abc123",
"idTag": "alice@example.com",
"name": "Alice Johnson"
},
"time": "2025-01-01T12:00:00Z"
}
Respond to QR Login
POST /api/auth/qr-login/{session_id}/respond
Approve or deny a QR login request from the mobile device.
Authentication: Required
Path Parameters:
session_id - The session ID from the QR code
Request:
| Field |
Type |
Required |
Description |
approved |
boolean |
Yes |
Whether to approve or deny the login |
Response:
{
"data": {
"status": "approved"
},
"time": "2025-01-01T12:00:00Z"
}
WebAuthn (Passkey) Authentication
WebAuthn enables passwordless authentication using passkeys (biometrics, security keys, etc.).
List Passkey Registrations
List all registered passkeys for the authenticated user.
Authentication: Required
Response:
{
"data": [
{
"keyId": "abc123",
"name": "MacBook Touch ID",
"createdAt": "2025-01-01T12:00:00Z",
"lastUsedAt": "2025-01-15T09:30:00Z"
}
],
"time": "2025-01-01T12:00:00Z"
}
Get Registration Challenge
GET /api/auth/wa/reg/challenge
Get a challenge for registering a new passkey.
Authentication: Required
Response:
{
"data": {
"challenge": "base64-encoded-challenge",
"rpId": "example.com",
"rpName": "Cloudillo",
"userId": "base64-user-id",
"userName": "alice@example.com"
},
"time": "2025-01-01T12:00:00Z"
}
Register Passkey
Complete passkey registration with the WebAuthn response.
Authentication: Required
Request:
{
"name": "MacBook Touch ID",
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"response": {
"clientDataJSON": "base64-client-data",
"attestationObject": "base64-attestation"
},
"type": "public-key"
}
}
Response:
{
"data": {
"keyId": "abc123",
"name": "MacBook Touch ID",
"createdAt": "2025-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Delete Passkey
DELETE /api/auth/wa/reg/{key_id}
Remove a registered passkey.
Authentication: Required
Path Parameters:
key_id - The passkey identifier to delete
Response:
{
"data": "ok",
"time": "2025-01-01T12:00:00Z"
}
Get Login Challenge
GET /api/auth/wa/login/challenge
Get a challenge for passkey authentication.
Authentication: Not required
Query Parameters:
idTag - User identity (optional, for usernameless flow)
Response:
{
"data": {
"challenge": "base64-encoded-challenge",
"rpId": "example.com",
"allowCredentials": [
{
"type": "public-key",
"id": "credential-id"
}
]
},
"time": "2025-01-01T12:00:00Z"
}
Login with Passkey
Authenticate using a passkey.
Authentication: Not required
Request:
{
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"response": {
"clientDataJSON": "base64-client-data",
"authenticatorData": "base64-auth-data",
"signature": "base64-signature"
},
"type": "public-key"
}
}
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice Johnson",
"token": "eyJhbGc...",
"roles": ["user"]
},
"time": "2025-01-01T12:00:00Z"
}
API Key Management
API keys enable programmatic access without interactive login.
List API Keys
List all API keys for the authenticated user.
Authentication: Required
Response:
{
"data": [
{
"keyId": "key_abc123",
"name": "CI/CD Pipeline",
"scope": "read:files,write:files",
"createdAt": "2025-01-01T12:00:00Z",
"lastUsedAt": "2025-01-15T09:30:00Z",
"expiresAt": "2026-01-01T12:00:00Z"
}
],
"time": "2025-01-01T12:00:00Z"
}
Create API Key
Create a new API key.
Authentication: Required
Request:
{
"name": "CI/CD Pipeline",
"scope": "read:files,write:files",
"expiresAt": "2026-01-01T12:00:00Z"
}
Response:
{
"data": {
"keyId": "key_abc123",
"name": "CI/CD Pipeline",
"key": "ck_live_abc123xyz...",
"scope": "read:files,write:files",
"createdAt": "2025-01-01T12:00:00Z",
"expiresAt": "2026-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Warning
The key field is only returned once at creation. Store it securely.
Get API Key
GET /api/auth/api-keys/{key_id}
Get details of a specific API key.
Authentication: Required
Path Parameters:
key_id - The API key identifier
Response:
{
"data": {
"keyId": "key_abc123",
"name": "CI/CD Pipeline",
"scope": "read:files,write:files",
"createdAt": "2025-01-01T12:00:00Z",
"lastUsedAt": "2025-01-15T09:30:00Z",
"expiresAt": "2026-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Update API Key
PATCH /api/auth/api-keys/{key_id}
Update an API key’s metadata.
Authentication: Required
Path Parameters:
key_id - The API key identifier
Request:
{
"name": "Production Pipeline",
"scope": "read:files"
}
Response:
{
"data": {
"keyId": "key_abc123",
"name": "Production Pipeline",
"scope": "read:files",
"createdAt": "2025-01-01T12:00:00Z",
"expiresAt": "2026-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Delete API Key
DELETE /api/auth/api-keys/{key_id}
Revoke and delete an API key.
Authentication: Required
Path Parameters:
key_id - The API key identifier
Response:
{
"data": "ok",
"time": "2025-01-01T12:00:00Z"
}
Public Endpoints
Get Tenant Profile (Public)
GET /api/me
GET /api/me/full
Get the tenant (server) profile with public keys. This is a public endpoint that returns the server’s identity information.
Note: Both paths return the same data; /full is an alias for compatibility.
Authentication: Not required
Response:
{
"data": {
"idTag": "server@example.com",
"name": "Example Server",
"publicKey": "-----BEGIN PUBLIC KEY-----...",
"serverInfo": {
"version": "1.0.0",
"features": ["federation", "crdt", "rtdb"]
}
},
"time": "2025-01-01T12:00:00Z"
}
Resolve Identity Tag
GET /.well-known/cloudillo/id-tag
Resolve a domain-based identity to a Cloudillo server. This is part of the DNS-based identity system.
Authentication: Not required
Query Parameters:
idTag - The identity to resolve (e.g., alice@example.com)
Response:
{
"data": {
"idTag": "alice@example.com",
"serverUrl": "https://cloudillo.example.com",
"publicKey": "-----BEGIN PUBLIC KEY-----..."
},
"time": "2025-01-01T12:00:00Z"
}
Get VAPID Public Key
Get the VAPID public key for push notification subscriptions.
Authentication: Required
Response:
{
"data": {
"publicKey": "BNxwfD..."
},
"time": "2025-01-01T12:00:00Z"
}
See Also
Profiles API
Overview
Manage user and community profiles, including registration and community creation.
Registration
Verify Profile
POST /api/profiles/verify
Verify identity availability before registration or community creation. This endpoint checks if an identity tag is available and can be used.
Authentication: Not required
Request:
{
"idTag": "alice@example.com",
"type": "idp"
}
| Field |
Type |
Required |
Description |
idTag |
string |
Yes |
The identity tag to verify |
type |
string |
Yes |
Identity type: idp (hosted) or domain (self-hosted) |
appDomain |
string |
No |
Application domain (for domain type) |
token |
string |
No |
Verification token (for unauthenticated requests) |
Response:
{
"data": {
"available": true,
"idTag": "alice@example.com"
},
"time": "2025-01-01T12:00:00Z"
}
Example:
const result = await api.profile.verify({
idTag: 'alice@example.com',
type: 'idp'
})
if (result.data.available) {
// Proceed with registration
}
Register User
POST /api/profiles/register
Register a new user account. This initiates the registration process and sends a verification email.
Authentication: Not required
Request:
{
"type": "idp",
"idTag": "alice@example.com",
"email": "alice@gmail.com",
"token": "verification-token",
"lang": "en"
}
| Field |
Type |
Required |
Description |
type |
string |
Yes |
Identity type: idp or domain |
idTag |
string |
Yes |
Desired identity tag |
email |
string |
Yes |
Email address for verification |
token |
string |
Yes |
Registration token |
appDomain |
string |
No |
Application domain (for domain type) |
lang |
string |
No |
Preferred language code |
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice",
"token": "eyJhbGc..."
},
"time": "2025-01-01T12:00:00Z"
}
Example:
const result = await api.profile.register({
type: 'idp',
idTag: 'alice@example.com',
email: 'alice@gmail.com',
token: 'registration-token'
})
// User is now registered and logged in
console.log('Registered:', result.data.idTag)
PUT /api/profiles/{id_tag}
Create a new community profile. The authenticated user becomes the community owner.
Authentication: Required
Path Parameters:
id_tag - The identity tag for the new community
Request:
{
"type": "idp",
"name": "Developer Community",
"profilePic": "b1~abc123",
"appDomain": "devs.example.com"
}
| Field |
Type |
Required |
Description |
type |
string |
Yes |
Identity type: idp or domain |
name |
string |
No |
Community display name |
profilePic |
string |
No |
Profile picture file ID |
appDomain |
string |
No |
Application domain (for domain type) |
Response:
{
"data": {
"idTag": "devs@example.com",
"name": "Developer Community",
"type": "community",
"createdAt": "2025-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Example:
// First verify the community name is available
const check = await api.profile.verify({
idTag: 'devs@example.com',
type: 'idp'
})
if (check.data.available) {
const response = await fetch('/api/profiles/devs@example.com', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'idp',
name: 'Developer Community'
})
})
const community = await response.json()
console.log('Created community:', community.data.idTag)
}
Community Ownership
The authenticated user who creates the community becomes the owner. Only the owner can manage community settings and membership.
Profile Management
Get Own Profile
Get the authenticated user’s profile (requires authentication). For the public server identity endpoint, see GET /api/me in the Authentication API.
Authentication: Required
Example:
const api = cloudillo.createApiClient()
const profile = await api.profiles.getOwn()
Response:
{
"data": {
"tnId": 12345,
"idTag": "alice@example.com",
"name": "Alice Johnson",
"profilePic": "/file/b1~abc123",
"x": {
"bio": "Software developer",
"location": "San Francisco"
}
}
}
Update Own Profile
Update the authenticated user’s profile.
Authentication: Required
Request:
await api.profiles.updateOwn({
name: 'Alice Smith',
x: {
bio: 'Senior developer',
website: 'https://alice.example.com'
}
})
Upload Profile Image
Upload or update your profile picture.
Authentication: Required
Content-Type: Image type (e.g., image/jpeg, image/png)
Request Body: Raw image binary data
Example:
const imageFile = document.querySelector('input[type="file"]').files[0]
const response = await fetch('/api/me/image', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': imageFile.type
},
body: imageFile
})
const result = await response.json()
console.log('Profile image updated:', result.data.profilePic)
Response:
{
"data": {
"profilePic": "/api/file/b1~abc123",
"fileId": "b1~abc123"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Upload Cover Image
Upload or update your cover photo.
Authentication: Required
Content-Type: Image type (e.g., image/jpeg, image/png)
Request Body: Raw image binary data
Example:
const coverFile = document.querySelector('input[type="file"]').files[0]
const response = await fetch('/api/me/cover', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': coverFile.type
},
body: coverFile
})
const result = await response.json()
console.log('Cover image updated:', result.data.coverPic)
Response:
{
"data": {
"coverPic": "/api/file/b1~xyz789",
"fileId": "b1~xyz789"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Get Profile
Get a specific user or community profile.
Example:
const profile = await api.profiles.get('bob@example.com')
List Profiles
List all accessible profiles.
Authentication: Required
Query Parameters:
type - Filter by type (person, community)
status - Filter by status (comma-separated)
connected - Filter by connection status (disconnected, pending, connected)
following - Filter by follow status (true/false)
q - Search query for name
idTag - Filter by specific identity tag
Example:
// List all communities
const communities = await api.profiles.list({
type: 'community'
})
// List connected profiles
const friends = await api.profiles.list({
connected: 'connected'
})
// Search for profiles
const results = await api.profiles.list({
q: 'alice'
})
Update Relationship
PATCH /api/profiles/:idTag
Update your relationship with another profile (follow, block, etc.).
Authentication: Required
Request:
{
"relationship": "follow"
}
Response:
{
"data": {
"idTag": "bob@example.com",
"relationship": "follow",
"updatedAt": 1735000000
},
"time": 1735000000,
"reqId": "req_abc123"
}
Admin: Update Profile
PATCH /api/admin/profiles/:idTag
Admin endpoint to update any user’s profile.
Authentication: Required (admin role)
Request:
{
"name": "Updated Name",
"status": "active",
"roles": ["user", "moderator"]
}
Response:
{
"data": {
"tnId": 12345,
"idTag": "bob@example.com",
"name": "Updated Name",
"status": "active",
"roles": ["user", "moderator"]
},
"time": 1735000000,
"reqId": "req_abc123"
}
See Also
Actions API
Overview
Actions represent social interactions and activities in Cloudillo. This includes posts, comments, reactions, connections, and more.
The Actions API allows you to:
- Create posts, comments, and reactions
- Manage user connections and follows
- Share files and resources
- Send messages
- Track action statistics
Action Types
Content Actions
| Type |
Description |
Audience |
Examples |
| POST |
Create a post or content |
Followers, Public, Custom |
Blog posts, status updates |
| CMNT |
Comment on an action |
Parent action audience |
Thread replies |
| REPOST |
Share existing content |
Followers |
Retweets |
| SHRE |
Share resource/link |
Followers, Custom |
Link sharing |
User Actions
| Type |
Description |
Audience |
Examples |
| FLLW |
Follow a user/community |
Target user |
Subscribe to updates |
| CONN |
Connection request |
Target user |
Friend requests |
Communication Actions
| Type |
Description |
Audience |
Examples |
| MSG |
Private message |
Specific user |
Direct messages |
| FSHR |
File sharing |
Specific user(s) |
Share documents |
| Type |
Description |
Audience |
Examples |
| REACT |
React to content |
None (broadcast) |
Likes, loves |
| ACK |
Acknowledgment |
Parent action issuer |
Read receipts |
Endpoints
List Actions
Query all actions with optional filters. Uses cursor-based pagination for stable results.
Authentication: Optional (visibility-based access control)
Query Parameters:
Filtering:
type - Filter by action type(s), comma-separated (e.g., POST,CMNT)
status - Filter by status(es), comma-separated (P=Pending, A=Active, D=Deleted, C=Created, N=New)
issuer - Filter by issuer identity (e.g., alice@example.com)
audience - Filter by audience identity
parentId - Filter by parent action (for comments/reactions)
rootId - Filter by root/thread ID (for nested comments)
subject - Filter by subject identity (for CONN, FLLW actions)
involved - Filter by actions involving a specific identity (issuer, audience, or subject)
actionId - Filter by specific action ID
tag - Filter by content tag
Time-based:
createdAfter - ISO 8601 timestamp or Unix seconds
Pagination:
limit - Max results (default: 20)
cursor - Opaque cursor for next page (from previous response)
Sorting:
sort - Sort field: created (default)
sortDir - Sort direction: asc or desc (default: desc)
Examples:
Get recent posts:
const api = cloudillo.createApiClient()
const posts = await api.actions.list({
type: 'POST',
status: 'A',
limit: 20,
sort: 'created',
sortDir: 'desc'
})
Get comments on a specific post:
const comments = await api.actions.list({
type: 'CMNT',
parentId: 'act_post123',
sort: 'created',
sortDir: 'asc'
})
Get all actions involving a specific user:
const userActivity = await api.actions.list({
involved: 'alice@example.com',
limit: 50
})
Get posts from a time range:
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const recentPosts = await api.actions.list({
type: 'POST',
status: 'A',
createdAfter: lastWeek,
limit: 100
})
Get pending connection requests:
const connectionRequests = await api.actions.list({
type: 'CONN',
status: 'P',
subject: cloudillo.idTag // Requests to me
})
Get thread with all nested comments:
const thread = await api.actions.list({
rootId: 'act_original_post',
type: 'CMNT',
sort: 'created',
sortDir: 'asc',
limit: 200
})
Cursor-based pagination:
// First page
const page1 = await api.actions.list({ type: 'POST', limit: 20 })
// Next page using cursor
if (page1.cursorPagination?.hasMore) {
const page2 = await api.actions.list({
type: 'POST',
limit: 20,
cursor: page1.cursorPagination.nextCursor
})
}
Response:
{
"data": [
{
"actionId": "act_abc123",
"type": "POST",
"issuer": {
"idTag": "alice@example.com",
"name": "Alice Johnson",
"profilePic": "/file/b1~abc"
},
"content": {
"text": "Hello, Cloudillo!",
"title": "My First Post"
},
"createdAt": "2025-01-01T12:00:00Z",
"visibility": "P",
"stat": {
"reactions": 5,
"comments": 3,
"ownReaction": "LOVE"
}
}
],
"cursorPagination": {
"nextCursor": "eyJzIjoiY3JlYXRlZCIsInYiOjE3MzUwMDAwMDAsImlkIjoiYWN0X2FiYzEyMyJ9",
"hasMore": true
},
"time": "2025-01-01T12:00:00Z"
}
Create Action
Create a new action (post, comment, reaction, etc.).
Authentication: Required
Request Body:
interface NewAction {
type: string // Action type (POST, CMNT, etc.)
subType?: string // Optional subtype/category
parentId?: string // For comments, reactions
rootId?: string // For deep threads
audienceTag?: string // Target audience
content?: unknown // Action-specific content
attachments?: string[] // File IDs
subject?: string // Target (e.g., who to follow)
expiresAt?: number // Expiration timestamp
}
Examples:
Create a Post:
const post = await api.actions.create({
type: 'POST',
content: {
text: 'Hello, Cloudillo!',
title: 'My First Post'
},
attachments: ['file_123', 'file_456']
})
Create a Comment:
const comment = await api.actions.create({
type: 'CMNT',
parentId: 'act_post123',
content: {
text: 'Great post!'
}
})
Create a Reaction:
const reaction = await api.actions.create({
type: 'REACT',
subType: 'LOVE',
parentId: 'act_post123'
})
Follow a User:
const follow = await api.actions.create({
type: 'FLLW',
subject: 'bob@example.com'
})
Connect with a User:
const connection = await api.actions.create({
type: 'CONN',
subject: 'bob@example.com',
content: {
message: 'Would like to connect!'
}
})
Share a File:
const share = await api.actions.create({
type: 'FSHR',
subject: 'bob@example.com',
attachments: ['file_789'],
content: {
permission: 'READ', // or 'WRITE'
message: 'Check out this document'
}
})
Response:
{
"data": {
"actionId": "act_newpost123",
"type": "POST",
"issuerTag": "alice@example.com",
"content": {
"text": "Hello, Cloudillo!",
"title": "My First Post"
},
"createdAt": 1735000000,
"status": "A"
}
}
Get Action
GET /api/actions/:actionId
Retrieve a specific action by ID.
Example:
const action = await api.actions.get('act_123')
Response:
{
"data": {
"actionId": "act_123",
"type": "POST",
"issuerTag": "alice@example.com",
"issuer": {
"idTag": "alice@example.com",
"name": "Alice Johnson",
"profilePic": "/file/b1~abc"
},
"content": {
"text": "Hello!",
"title": "Greeting"
},
"createdAt": 1735000000,
"stat": {
"reactions": 10,
"comments": 5,
"ownReaction": null
}
}
}
Update Draft Action
PATCH /api/actions/{action_id}
Update a draft action before publishing. Only actions in draft status (R) can be updated.
Authentication: Required (must be issuer)
Request Body:
{
"content": {
"text": "Updated post content",
"title": "Updated Title"
},
"attachments": ["b1~file123"],
"visibility": "P"
}
| Field |
Type |
Description |
content |
object |
Updated content (action-type specific) |
attachments |
string[] |
Updated file attachment IDs |
visibility |
string |
Visibility level (P, V, F, C) |
flags |
string |
Action flags |
x |
object |
Extension data |
Response:
{
"data": {
"actionId": "act_123",
"type": "POST",
"status": "R",
"content": {
"text": "Updated post content",
"title": "Updated Title"
}
},
"time": "2025-01-01T12:00:00Z"
}
Info
Only draft actions (status R) can be updated. Published actions are signed JWTs and cannot be modified.
Delete Action
DELETE /api/actions/:actionId
Delete an action. Only the issuer can delete their actions.
Authentication: Required (must be issuer)
Example:
await api.actions.delete('act_123')
Response:
Accept Action
POST /api/actions/:actionId/accept
Accept a pending action (e.g., connection request, follow request).
Authentication: Required (must be the subject/target)
DSL Hooks: When an action is accepted, the server triggers the on_accept hook defined in the action type’s DSL configuration. This can execute custom logic such as:
- Creating reciprocal connections (CONN actions)
- Adding the user to groups (INVT actions)
- Granting permissions (FSHR actions)
Example:
// Accept a connection request
await api.actions.accept('act_connreq123')
// Accept a follow request (for private accounts)
await api.actions.accept('act_follow456')
Response:
{
"data": {
"actionId": "act_connreq123",
"status": "A"
}
}
Reject Action
POST /api/actions/:actionId/reject
Reject a pending action.
Authentication: Required (must be the subject/target)
DSL Hooks: When an action is rejected, the server triggers the on_reject hook defined in the action type’s DSL configuration. This can execute cleanup logic such as:
- Removing pending permissions
- Notifying the issuer of the rejection
- Cleaning up temporary resources
Example:
await api.actions.reject('act_connreq123')
Response:
{
"data": {
"actionId": "act_connreq123",
"status": "D"
}
}
Update Statistics
POST /api/actions/:actionId/stat
Update action statistics (typically called by the system).
Authentication: Required (admin)
Request Body:
{
reactions?: number
comments?: number
views?: number
}
Dismiss Notification
POST /api/actions/{action_id}/dismiss
Dismiss a notification action. This acknowledges a notification without accepting or rejecting it.
Authentication: Required
Request Body: Empty
Response:
{
"data": null,
"time": "2025-01-01T12:00:00Z"
}
Publish Draft
POST /api/actions/{action_id}/publish
Publish a draft action, making it visible to the intended audience. Optionally schedule for future publication.
Authentication: Required (must be issuer)
Request Body:
{
"publishAt": "2025-02-01T12:00:00Z"
}
| Field |
Type |
Description |
publishAt |
string |
Optional future publication timestamp (ISO 8601). If omitted, publishes immediately. |
Response:
{
"data": {
"actionId": "act_123",
"type": "POST",
"status": "A"
},
"time": "2025-01-01T12:00:00Z"
}
Info
If publishAt is provided, the action moves to scheduled status (S) and will be published automatically at the specified time.
Cancel Scheduled Action
POST /api/actions/{action_id}/cancel
Cancel a scheduled action, reverting it back to draft status.
Authentication: Required (must be issuer)
Request Body: Empty
Response:
{
"data": {
"actionId": "act_123",
"type": "POST",
"status": "R"
},
"time": "2025-01-01T12:00:00Z"
}
Federation Inbox
Receive federated actions from other Cloudillo instances. This is the primary endpoint for cross-instance action delivery. Processing is asynchronous.
Authentication: Not required (actions are verified via signatures)
Request Body: Action token (JWT)
Example:
// This is typically called by other Cloudillo servers
const actionToken = 'eyJhbGc...' // Signed action token
const response = await fetch('/api/inbox', {
method: 'POST',
headers: {
'Content-Type': 'application/jwt'
},
body: actionToken
})
Response:
{
"data": {
"actionId": "act_federated123",
"status": "received",
"verified": true
},
"time": "2025-01-01T12:00:00Z"
}
Federation Inbox (Synchronous)
Receive federated actions with synchronous processing. Unlike the standard inbox, this endpoint processes the action immediately and returns the result.
Authentication: Not required (actions are verified via signatures)
Request Body: Action token (JWT)
Response:
{
"data": {
"actionId": "act_federated123",
"status": "processed",
"verified": true
},
"time": "2025-01-01T12:00:00Z"
}
Info
Both inbox endpoints verify the action signature against the issuer’s public key before accepting it.
Action Status Flow
Actions have a lifecycle represented by status:
R (Draft) → S (Scheduled) → A (Active/Published)
→ A (Active) ↘ R (Cancelled)
↘ D (Deleted)
C (Created) → A (Active/Accepted)
↘ D (Deleted/Rejected)
N (New) → (Dismissed)
P (Pending) → A (Active)
↘ D (Deleted)
- R (Draft): Unpublished draft, can be edited
- S (Scheduled): Scheduled for future publication
- A (Active): Published, visible, and finalized
- D (Deleted): Soft-deleted or rejected
- C (Created): Awaiting acceptance (e.g., connection requests)
- N (New): Notification awaiting acknowledgment
- P (Pending): Legacy pending status
Status transitions:
- Draft actions can be updated, published, scheduled, or deleted
- Scheduled actions can be cancelled (reverts to Draft) or auto-publish at scheduled time
- Created actions can be accepted (→ Active) or rejected (→ Deleted)
- New notifications can be dismissed
- Any action can be deleted by issuer (changes status to Deleted)
Content Schemas
Different action types have different content structures:
POST Content
{
title?: string
text?: string
summary?: string
category?: string
tags?: string[]
}
CMNT Content
MSG Content
{
text: string
subject?: string
}
FSHR Content
{
permission: 'READ' | 'WRITE'
message?: string
expiresAt?: number
}
CONN Content
Action Statistics
Actions can have aggregated statistics:
interface ActionStat {
reactions?: number // Total reactions
comments?: number // Total comments
commentsRead?: number // Comments user has read
ownReaction?: string // User's own reaction type
views?: number // View count
shares?: number // Share count
}
Accessing statistics:
const action = await api.actions.get('act_123')
console.log('Reactions:', action.stat?.reactions)
console.log('Comments:', action.stat?.comments)
console.log('My reaction:', action.stat?.ownReaction)
Threading
Actions support threading via parentId and rootId:
POST (root action)
└── CMNT (comment)
└── CMNT (reply to comment)
└── CMNT (nested reply)
Creating a thread:
// Original post
const post = await api.actions.create({
type: 'POST',
content: { text: 'Main post' }
})
// First level comment
const comment1 = await api.actions.create({
type: 'CMNT',
parentId: post.actionId,
rootId: post.actionId,
content: { text: 'A comment' }
})
// Reply to comment
const reply = await api.actions.create({
type: 'CMNT',
parentId: comment1.actionId,
rootId: post.actionId, // Still points to original post
content: { text: 'A reply' }
})
Fetching a thread:
// Get all comments in a thread
const thread = await api.actions.list({
rootId: 'act_post123',
type: 'CMNT',
sort: 'created',
sortDir: 'asc'
})
Federation
Actions are the core of Cloudillo’s federation model. Each action is cryptographically signed and can be verified across instances.
Action Token Structure:
Header: { alg: "ES384", typ: "JWT" }
Payload: {
actionId: "act_123",
type: "POST",
issuerTag: "alice@example.com",
content: {...},
createdAt: 1735000000,
iat: 1735000000,
exp: 1735086400
}
Signature: <ES384 signature>
This enables:
- Trust-free verification
- Cross-instance action delivery
- Tamper-proof audit trails
Best Practices
// ✅ Use cursor pagination for stable results
async function fetchAllPosts() {
const allPosts = []
let cursor = undefined
while (true) {
const result = await api.actions.list({
type: 'POST',
limit: 50,
cursor
})
allPosts.push(...result.data)
if (!result.cursorPagination?.hasMore) break
cursor = result.cursorPagination.nextCursor
}
return allPosts
}
2. Optimistic UI Updates
// Update UI immediately, rollback on error
const optimisticAction = {
actionId: 'temp_' + Date.now(),
type: 'POST',
content: { text: 'New post' },
issuer: { idTag: cloudillo.idTag },
createdAt: new Date().toISOString()
}
setPosts([optimisticAction, ...posts])
try {
const created = await api.actions.create(optimisticAction)
setPosts(posts => posts.map(p =>
p.actionId === optimisticAction.actionId ? created : p
))
} catch (error) {
setPosts(posts => posts.filter(p =>
p.actionId !== optimisticAction.actionId
))
}
3. Handle Visibility
Actions have visibility levels that control who can see them:
| Code |
Level |
Description |
P |
Public |
Anyone can view |
V |
Verified |
Authenticated users only |
F |
Follower |
User’s followers only |
C |
Connected |
Mutual connections only |
null |
Direct |
Owner + explicit audience |
// Create a public post
const publicPost = await api.actions.create({
type: 'POST',
visibility: 'P',
content: { text: 'Hello everyone!' }
})
// Create a followers-only post
const followersPost = await api.actions.create({
type: 'POST',
visibility: 'F',
content: { text: 'Just for my followers' }
})
See Also
Files API
Overview
The Files API handles file upload, download, and management in Cloudillo. It supports four file types: BLOB (binary files), CRDT (collaborative documents), RTDB (real-time databases), and FLDR (folders).
File Types
| Type |
Description |
Use Cases |
| BLOB |
Binary files (images, PDFs, etc.) |
Photos, documents, attachments |
| CRDT |
Collaborative documents |
Rich text, spreadsheets, diagrams |
| RTDB |
Real-time databases |
Structured data, forms, todos |
| FLDR |
Folders |
Organize files hierarchically |
File Status
| Code |
Status |
Description |
A |
Active |
File is available |
P |
Pending |
File is being processed |
D |
Deleted |
File is in trash |
Image Variants
For BLOB images, Cloudillo automatically generates 5 variants:
| Variant |
Code |
Max Dimension |
Quality |
Format |
| Thumbnail |
tn |
150px |
Medium |
JPEG/WebP |
| Icon |
ic |
64px |
Medium |
JPEG/WebP |
| SD |
sd |
640px |
Medium |
JPEG/WebP |
| HD |
hd |
1920px |
High |
JPEG/WebP |
| Original |
orig |
Original |
Original |
Original |
Automatic format selection:
- Modern browsers: WebP or AVIF
- Fallback: JPEG or PNG
Endpoints
List Files
List all files accessible to the user. Uses cursor-based pagination.
Authentication: Optional (visibility-based access control)
Query Parameters:
Filtering:
fileTp - Filter by file type (BLOB, CRDT, RTDB, FLDR)
contentType - Filter by MIME type
tags - Filter by tags (comma-separated)
parentId - Filter by parent folder (for hierarchical listing)
status - Filter by status (A, P, D)
Pagination:
limit - Max results (default: 20)
cursor - Opaque cursor for next page
Sorting:
sort - Sort field: created, modified, name, recent
sortDir - Sort direction: asc or desc
Example:
const api = cloudillo.createApiClient()
// List all images
const images = await api.files.list({
fileTp: 'BLOB',
contentType: 'image/*',
limit: 20
})
// List files in a folder
const folderContents = await api.files.list({
parentId: 'f1~folder123',
sort: 'name',
sortDir: 'asc'
})
// List tagged files
const projectFiles = await api.files.list({
tags: 'project-alpha,important'
})
// Cursor-based pagination
if (images.cursorPagination?.hasMore) {
const page2 = await api.files.list({
fileTp: 'BLOB',
cursor: images.cursorPagination.nextCursor
})
}
Response:
{
"data": [
{
"fileId": "b1~abc123",
"parentId": null,
"status": "A",
"contentType": "image/png",
"fileName": "photo.png",
"fileTp": "BLOB",
"createdAt": "2025-01-01T12:00:00Z",
"tags": ["vacation", "beach"],
"visibility": "P",
"owner": {
"idTag": "alice@example.com",
"name": "Alice"
},
"userData": {
"pinned": false,
"starred": true,
"accessedAt": "2025-01-15T09:30:00Z"
}
}
],
"cursorPagination": {
"nextCursor": "eyJzIjoiY3JlYXRlZCIsInYiOjE3MzUwMDAwMDAsImlkIjoiYjF-YWJjMTIzIn0",
"hasMore": true
},
"time": "2025-01-01T12:00:00Z"
}
Create file metadata for CRDT or RTDB files. For BLOB files, use the one-step upload endpoint instead (see “Upload File (BLOB)” below).
Authentication: Required
Request Body:
{
fileTp: string // CRDT or RTDB
fileName?: string // Optional filename
tags?: string // Comma-separated tags
}
Example:
// Create CRDT document
const file = await api.files.create({
fileTp: 'CRDT',
fileName: 'team-doc.crdt',
tags: 'collaborative,document'
})
console.log('File ID:', file.fileId) // e.g., "f1~abc123"
// Now connect via WebSocket to edit the document
import * as Y from 'yjs'
const yDoc = new Y.Doc()
const { provider } = await cloudillo.openYDoc(yDoc, file.fileId)
Response:
{
"data": {
"fileId": "f1~abc123",
"status": "P",
"fileTp": "CRDT",
"fileName": "team-doc.crdt",
"createdAt": 1735000000
},
"time": 1735000000,
"reqId": "req_abc123"
}
Note: For BLOB files (images, PDFs, etc.), use POST /api/files/{preset}/{file_name} instead, which creates metadata and uploads the file in a single step.
Upload File (BLOB)
POST /api/files/{preset}/{file_name}
Upload binary file data directly. This creates the file metadata and uploads the binary in a single operation.
Authentication: Required
Path Parameters:
preset (string, required) - Image processing preset (e.g., default, profile-picture, cover-photo)
file_name (string, required) - Original filename with extension
Query Parameters:
created_at (number, optional) - Unix timestamp (seconds) for when the file was created
tags (string, optional) - Comma-separated tags (e.g., vacation,beach,2025)
Content-Type: Binary content type (e.g., image/png, image/jpeg, application/pdf)
Request Body: Raw binary file data
Example:
const api = cloudillo.createApiClient()
// Upload image file
const imageFile = document.querySelector('input[type="file"]').files[0]
const blob = await imageFile.arrayBuffer()
const response = await fetch('/api/files/default/vacation-photo.jpg?tags=vacation,beach', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'image/jpeg'
},
body: blob
})
const result = await response.json()
console.log('File ID:', result.data.fileId) // e.g., "b1~abc123"
Complete upload with File object:
async function uploadFile(file: File, preset = 'default', tags?: string) {
const queryParams = new URLSearchParams()
if (tags) queryParams.set('tags', tags)
const url = `/api/files/${preset}/${encodeURIComponent(file.name)}${
queryParams.toString() ? '?' + queryParams.toString() : ''
}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': file.type
},
body: file
})
const result = await response.json()
return result.data.fileId
}
// Usage
const fileInput = document.querySelector('input[type="file"]')
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0]
const fileId = await uploadFile(file, 'default', 'vacation,beach')
console.log('Uploaded:', fileId)
// Display uploaded image
const img = document.createElement('img')
img.src = `/api/files/${fileId}?variant=sd`
document.body.appendChild(img)
})
Response:
{
"data": {
"fileId": "b1~abc123",
"status": "M",
"fileTp": "BLOB",
"contentType": "image/jpeg",
"fileName": "vacation-photo.jpg",
"createdAt": 1735000000,
"tags": ["vacation", "beach"]
},
"time": 1735000000,
"reqId": "req_abc123"
}
Download File
Download a file. Returns binary data with appropriate Content-Type.
Query Parameters:
variant - Image variant (tn, ic, sd, hd, orig)
Example:
// Direct URL usage
<img src="/api/files/b1~abc123" />
// Get specific variant
<img src="/api/files/b1~abc123?variant=sd" />
// Fetch with API
const response = await fetch(`/api/files/${fileId}`)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
Responsive images:
<picture>
<source srcset="/api/files/b1~abc123?variant=hd" media="(min-width: 1200px)">
<source srcset="/api/files/b1~abc123?variant=sd" media="(min-width: 600px)">
<img src="/api/files/b1~abc123?variant=tn" alt="Photo">
</picture>
Get File Descriptor
GET /api/files/:fileId/descriptor
Get file metadata and available variants.
Example:
const descriptor = await api.files.getDescriptor('b1~abc123')
console.log('File type:', descriptor.contentType)
console.log('Size:', descriptor.size)
console.log('Variants:', descriptor.variants)
Response:
{
"data": {
"fileId": "b1~abc123",
"contentType": "image/png",
"fileName": "photo.png",
"size": 1048576,
"createdAt": "2025-01-01T12:00:00Z",
"variants": [
{
"id": "tn",
"width": 150,
"height": 100,
"size": 5120,
"format": "webp"
},
{
"id": "sd",
"width": 640,
"height": 426,
"size": 51200,
"format": "webp"
},
{
"id": "hd",
"width": 1920,
"height": 1280,
"size": 204800,
"format": "webp"
},
{
"id": "orig",
"width": 3840,
"height": 2560,
"size": 1048576,
"format": "png"
}
]
}
}
GET /api/files/{file_id}/metadata
Get file metadata only (without variant information).
Authentication: Optional (visibility-based access control)
Path Parameters:
Response:
{
"data": {
"fileId": "b1~abc123",
"status": "A",
"contentType": "image/png",
"fileName": "photo.png",
"fileTp": "BLOB",
"createdAt": "2025-01-01T12:00:00Z",
"tags": ["vacation", "beach"],
"visibility": "P",
"owner": {
"idTag": "alice@example.com",
"name": "Alice"
}
},
"time": "2025-01-01T12:00:00Z"
}
Duplicate File
POST /api/files/{file_id}/duplicate
Create a copy of a CRDT or RTDB file. The new file gets a new ID and timestamps but copies the content.
Authentication: Required
Path Parameters:
file_id - The file ID to duplicate
Request Body:
{
"fileName": "Copy of team-doc.crdt",
"parentId": "f1~folder456"
}
| Field |
Type |
Required |
Description |
fileName |
string |
No |
Name for the new file (defaults to original name) |
parentId |
string |
No |
Parent folder for the new file |
Response:
{
"data": {
"fileId": "f1~newfile789"
},
"time": "2025-01-01T12:00:00Z"
}
Info
File duplication is only available for CRDT and RTDB file types. BLOB files cannot be duplicated through this endpoint.
Update file metadata (tags, filename, etc.).
Authentication: Required (must be owner)
Request Body:
{
fileName?: string
tags?: string // Comma-separated
}
Example:
await api.files.update('b1~abc123', {
fileName: 'renamed-photo.png',
tags: 'vacation,beach,2025'
})
Delete File
DELETE /api/files/{file_id}
Move a file to trash (soft delete).
Authentication: Required (must have write access)
Example:
await api.files.delete('b1~abc123')
Response:
{
"data": "ok",
"time": "2025-01-01T12:00:00Z"
}
Restore File
POST /api/files/{file_id}/restore
Restore a file from trash.
Authentication: Required (must have write access)
Example:
await fetch('/api/files/b1~abc123/restore', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
})
Response:
{
"data": "ok",
"time": "2025-01-01T12:00:00Z"
}
Empty Trash
Permanently delete all files in trash.
Authentication: Required
Example:
await fetch('/api/trash', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
Response:
{
"data": {
"deleted": 15
},
"time": "2025-01-01T12:00:00Z"
}
Update User File Data
PATCH /api/files/{file_id}/user
Update user-specific file metadata (pinned, starred). This updates only the authenticated user’s relationship with the file, not the file itself. Users can pin/star any file they have read access to.
Authentication: Required
Request:
{
"pinned": true,
"starred": false
}
Response:
{
"data": {
"pinned": true,
"starred": false,
"accessedAt": "2025-01-01T12:00:00Z"
},
"time": "2025-01-01T12:00:00Z"
}
Example:
// Star a file
await fetch('/api/files/b1~abc123/user', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ starred: true })
})
List all tags used in files owned by the authenticated user.
Authentication: Required
Response:
{
"data": [
{
"tag": "vacation",
"count": 15
},
{
"tag": "project-alpha",
"count": 8
},
{
"tag": "important",
"count": 3
}
]
}
Example:
const response = await fetch('/api/tags', {
headers: {
'Authorization': `Bearer ${token}`
}
})
const { data: tags } = await response.json()
// Display tags with counts
tags.forEach(({ tag, count }) => {
console.log(`${tag}: ${count} files`)
})
Add Tag
PUT /api/files/:fileId/tag/:tag
Add a tag to a file.
Authentication: Required (must be owner)
Example:
await fetch(`/api/files/b1~abc123/tag/important`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
}
})
Remove Tag
DELETE /api/files/:fileId/tag/:tag
Remove a tag from a file.
Authentication: Required (must be owner)
Example:
await fetch(`/api/files/b1~abc123/tag/important`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
Get File Variant
GET /api/files/variant/:variantId
Get a specific image variant directly.
Example:
// Variant IDs are in the format: {fileId}~{variant}
<img src="/api/files/variant/b1~abc123~sd" />
File Identifiers
Cloudillo uses content-addressable identifiers:
Format: {prefix}{version}~{hash}
Prefixes:
b - BLOB files
f - CRDT files
r - RTDB files
Examples:
b1~abc123def - BLOB file
f1~xyz789ghi - CRDT file
r1~mno456pqr - RTDB file
Variants:
b1~abc123def~sd - SD variant of BLOB
b1~abc123def~tn - Thumbnail variant
CRDT Files
CRDT files store collaborative documents using Yjs.
Creating a CRDT file:
// 1. Create file metadata
const file = await api.files.create({
fileTp: 'CRDT',
tags: 'document,collaborative'
})
// 2. Open for editing
import * as Y from 'yjs'
const yDoc = new Y.Doc()
const { provider } = await cloudillo.openYDoc(yDoc, file.fileId)
// 3. Use shared types
const yText = yDoc.getText('content')
yText.insert(0, 'Hello, CRDT!')
See CRDT documentation for details.
RTDB Files
RTDB files store structured real-time databases.
Creating an RTDB file:
// 1. Create file metadata
const file = await api.files.create({
fileTp: 'RTDB',
tags: 'database,todos'
})
// 2. Connect to database
import { RtdbClient } from '@cloudillo/rtdb'
const rtdb = new RtdbClient({
fileId: file.fileId,
token: cloudillo.accessToken,
url: 'wss://server.com/ws/rtdb'
})
// 3. Use collections
const todos = rtdb.collection('todos')
await todos.create({ title: 'Learn Cloudillo', done: false })
See RTDB documentation for details.
Image Presets
Configure automatic image processing with presets:
const file = await api.files.create({
fileTp: 'BLOB',
contentType: 'image/jpeg',
preset: 'profile-picture' // Custom preset
})
Default presets:
default - Standard 5-variant generation
profile-picture - Square crop, 400x400 max
cover-photo - 16:9 crop, 1920x1080 max
thumbnail-only - Only generate thumbnails
Tagging
Tags help organize and filter files.
Best practices:
- Use lowercase tags
- Use hyphens for multi-word tags (e.g.,
project-alpha)
- Limit to 3-5 tags per file
- Use namespaced tags for projects (e.g.,
proj:alpha, proj:beta)
Tag filtering:
// Files with ANY of these tags
const files = await api.files.list({
tags: 'vacation,travel'
})
// Files with ALL of these tags (use multiple requests)
const vacationFiles = await api.files.list({ tags: 'vacation' })
const summerFiles = vacationFiles.data.filter(f =>
f.tags?.includes('summer')
)
Permissions
File access is controlled by:
- Ownership - Owner has full access
- FSHR actions - Files shared via FSHR actions grant temporary access
- Public files - Files attached to public actions are publicly accessible
- Audience - Files attached to actions inherit action audience permissions
Sharing a file:
// Share file with read access
await api.actions.create({
type: 'FSHR',
subject: 'bob@example.com',
attachments: ['b1~abc123'],
content: {
permission: 'READ', // or 'WRITE'
message: 'Check out this photo!'
}
})
Storage Considerations
File size limits:
- Free tier: 100 MB per file
- Pro tier: 1 GB per file
- Enterprise: Configurable
Total storage:
- Free tier: 10 GB
- Pro tier: 100 GB
- Enterprise: Unlimited
Variant generation:
- Only for image files (JPEG, PNG, WebP, AVIF, GIF)
- Automatic async processing
- Lanczos3 filtering for high quality
- Progressive JPEG for faster loading
Best Practices
// ✅ Correct order
const metadata = await api.files.create({ fileTp: 'BLOB', contentType: 'image/png' })
await uploadBinary(metadata.fileId, imageBlob)
// ❌ Wrong - upload will fail without metadata
await uploadBinary('unknown-id', imageBlob)
2. Use Appropriate Variants
// ✅ Use thumbnails in lists
<img src={`/api/files/${fileId}?variant=tn`} />
// ✅ Use HD for detail views
<img src={`/api/files/${fileId}?variant=hd`} />
// ❌ Don't use original for thumbnails (wastes bandwidth)
<img src={`/api/files/${fileId}`} width="100" />
3. Handle Upload Errors
async function uploadWithRetry(file: File, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const result = await api.files.uploadBlob('default', file.name, file, file.type)
return result.fileId
} catch (error) {
if (i === maxRetries - 1) throw error
await new Promise(r => setTimeout(r, 1000 * (i + 1)))
}
}
}
4. Progressive Enhancement
// Show preview immediately, upload in background
function previewAndUpload(file: File) {
// Show local preview
const reader = new FileReader()
reader.onload = (e) => {
setPreview(e.target.result)
}
reader.readAsDataURL(file)
// Upload in background
uploadImage(file).then(fileId => {
setUploadedId(fileId)
})
}
5. Clean Up Unused Files
// Delete old temporary files
const oldFiles = await api.files.list({
tags: 'temp',
createdBefore: Date.now() / 1000 - 86400 // 24 hours ago
})
for (const file of oldFiles.data) {
await api.files.delete(file.fileId)
}
See Also
Settings API
Settings API
User preferences and configuration key-value store.
Settings Scopes
Settings operate at different scopes with cascading resolution:
| Scope |
Description |
Permission |
system |
Server-wide defaults (read-only) |
Requires restart to change |
global |
Shared across all tenants (tn_id=0) |
Admin only |
tenant |
User-specific settings |
Owner |
Permission Levels
| Level |
Access |
system |
Non-changeable (compile-time) |
admin |
Requires SADM role |
user |
Any authenticated user |
Resolution Order
When retrieving a setting value, the system resolves in this order:
- Tenant-specific value (if exists)
- Global value (if exists)
- System default
Endpoints
List Settings
Get all settings for the authenticated user.
Authentication: Required
Response:
{
"data": [
["theme", "dark"],
["language", "en"],
["notifications", "true"]
]
}
Get Setting
Get a specific setting value.
Example:
const theme = await api.settings.name('theme').get()
Update Setting
Set or update a setting value.
Request:
await api.settings.name('theme').put('dark')
Usage
// Get all settings
const settings = await api.settings.get()
// Get specific setting
const theme = await api.settings.name('theme').get()
// Update setting
await api.settings.name('theme').put('light')
await api.settings.name('fontSize').put(16)
await api.settings.name('notifications').put(true)
Common Settings
theme - UI theme preference
language - User language
fontSize - Text size
notifications - Enable/disable notifications
darkMode - Dark mode preference
Apps API
Overview
The Apps API manages app packages (APKGs) in Cloudillo. Apps are microfrontend plugins that extend the platform with new functionality.
Endpoints
List Available Apps
List all available apps published on the platform.
Authentication: Optional
Query Parameters:
search - Search term (matches app name, description, tags)
Response:
{
"data": [
{
"name": "docillo",
"publisherTag": "apps.cloudillo.org",
"version": "1.0.0",
"actionId": "a1~abc123",
"fileId": "b1~def456",
"capabilities": ["crdt", "rtdb"]
}
]
}
Example:
curl "https://cl-o.alice.cloudillo.net/api/apps?search=document"
Install App
Install an app from an APKG action.
Authentication: Required
Permission: Requires app management permission (leader-level)
Request Body:
{
"actionId": "a1~abc123"
}
Response (201 Created):
{
"data": {
"appName": "docillo",
"publisherTag": "apps.cloudillo.org",
"version": "1.0.0",
"actionId": "a1~abc123",
"fileId": "b1~def456",
"status": "A",
"capabilities": ["crdt", "rtdb"],
"autoUpdate": true
}
}
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"actionId":"a1~abc123"}' \
"https://cl-o.alice.cloudillo.net/api/apps/install"
List Installed Apps
List all apps installed on the current tenant.
Authentication: Required
Permission: Requires app management permission (leader-level)
Response:
{
"data": [
{
"appName": "docillo",
"publisherTag": "apps.cloudillo.org",
"version": "1.0.0",
"actionId": "a1~abc123",
"fileId": "b1~def456",
"status": "A",
"capabilities": ["crdt", "rtdb"],
"autoUpdate": true
}
]
}
Uninstall App
DELETE /api/apps/@{publisher}/{name}
Uninstall an app.
Authentication: Required
Permission: Requires app management permission (leader-level)
Path Parameters:
publisher - Publisher identity tag
name - App name
Response: 204 No Content
Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/apps/@apps.cloudillo.org/docillo"
Get App Container Content
GET /api/files/{file_id}/content/{path}
Serve static assets from an installed app package. Used by the shell to load app resources.
Authentication: Optional
Path Parameters:
file_id - The app package file ID
path - Path to the asset within the package (e.g., index.html, assets/main.js)
Response: The file content with appropriate MIME type headers.
See Also
References API
Overview
References (refs) are shareable tokens for various workflows including file sharing, email verification, password reset, and invitations. They support configurable expiration, usage limits, and access levels.
Endpoints
List References
GET /api/refs
List references for the current tenant.
Authentication: Required
Query Parameters:
| Parameter |
Type |
Required |
Description |
type |
string |
No |
Filter by type (e.g., share.file, email-verify) |
filter |
string |
No |
Status filter: active, used, expired, all (default: active) |
resourceId |
string |
No |
Filter by resource ID (e.g., file ID) |
Response:
{
"data": [
{
"refId": "abc123def456",
"type": "share.file",
"description": "Project document",
"createdAt": 1735000000,
"expiresAt": 1735604800,
"count": null,
"resourceId": "f1~xyz789",
"accessLevel": "read"
}
],
"pagination": {
"offset": 0,
"limit": 10,
"total": 1
},
"time": 1735000000,
"reqId": "req_abc123"
}
Example:
// List all active file shares
const shares = await api.refs.list({
type: 'share.file',
filter: 'active'
})
// List shares for a specific file
const fileShares = await api.refs.list({
resourceId: 'f1~xyz789'
})
Create Reference
POST /api/refs
Create a new reference token.
Authentication: Required
Request:
{
"type": "share.file",
"description": "Project document",
"expiresAt": 1735604800,
"count": null,
"resourceId": "f1~xyz789",
"accessLevel": "read"
}
| Field |
Type |
Required |
Description |
type |
string |
Yes |
Reference type |
description |
string |
No |
Human-readable description |
expiresAt |
number |
No |
Expiration timestamp (Unix seconds) |
count |
number/null |
No |
Usage count: omit for 1, null for unlimited, or number |
resourceId |
string |
No |
Resource ID (required for share.file) |
accessLevel |
string |
No |
Access level: read or write (default: read) |
Response:
{
"data": {
"refId": "abc123def456",
"type": "share.file",
"description": "Project document",
"createdAt": 1735000000,
"expiresAt": 1735604800,
"count": null,
"resourceId": "f1~xyz789",
"accessLevel": "read"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Example:
// Create a read-only share link with unlimited uses
const shareLink = await api.refs.create({
type: 'share.file',
description: 'Team document',
resourceId: 'f1~xyz789',
accessLevel: 'read',
count: null // Unlimited uses
})
// Create a write-access share link (single use)
const editLink = await api.refs.create({
type: 'share.file',
resourceId: 'f1~xyz789',
accessLevel: 'write'
// count omitted = single use
})
Get Reference
GET /api/refs/{refId}
Get details of a specific reference. Returns full details if authenticated, minimal details (only refId and type) if not.
Authentication: Optional
Path Parameters:
| Parameter |
Type |
Description |
refId |
string |
Reference ID |
Response (authenticated):
{
"data": {
"refId": "abc123def456",
"type": "share.file",
"description": "Project document",
"createdAt": 1735000000,
"expiresAt": 1735604800,
"count": 5,
"resourceId": "f1~xyz789",
"accessLevel": "read"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Response (unauthenticated):
{
"data": {
"refId": "abc123def456",
"type": "share.file"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Delete Reference
DELETE /api/refs/{refId}
Delete/revoke a reference. The reference will immediately become invalid.
Authentication: Required
Path Parameters:
| Parameter |
Type |
Description |
refId |
string |
Reference ID to delete |
Response:
{
"data": null,
"time": 1735000000,
"reqId": "req_abc123"
}
Guest Access with References
References can be used to grant guest access to resources without requiring authentication.
Exchange Reference for Access Token
GET /api/auth/access-token
Exchange a reference for a scoped access token. This enables unauthenticated users to access shared resources.
Authentication: None
Query Parameters:
| Parameter |
Type |
Required |
Description |
ref |
string |
Yes |
Reference ID |
Response:
{
"data": {
"token": "eyJhbGc...",
"expiresAt": 1735086400,
"accessLevel": "read",
"resourceId": "f1~xyz789"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Example:
// Guest accessing a shared file
const { token, accessLevel } = await api.auth.getAccessToken({
ref: 'abc123def456'
})
// Use token for subsequent API calls
const file = await api.files.get('f1~xyz789', {
headers: { 'Authorization': `Bearer ${token}` }
})
Reference Types
| Type |
Description |
Requires resourceId |
share.file |
File sharing link |
Yes |
email-verify |
Email verification |
No |
password-reset |
Password reset link |
No |
invite |
User invitation |
No |
welcome |
Welcome/onboarding link |
No |
Usage Count Behavior
The count field controls how many times a reference can be used:
| Value |
Behavior |
| Omitted |
Single use (default = 1) |
null |
Unlimited uses |
| Number |
That many uses remaining |
Each use decrements the count. When count reaches 0, the reference becomes invalid.
Access Levels
For share.file references, the accessLevel controls permissions:
| Level |
Description |
read |
View-only access (default) |
write |
Edit access to the resource |
Complete File Sharing Example
// 1. Create a shareable link
const share = await api.refs.create({
type: 'share.file',
description: 'Quarterly report',
resourceId: 'f1~xyz789',
accessLevel: 'read',
count: null, // Unlimited
expiresAt: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60 // 1 week
})
// 2. Generate share URL
const shareUrl = `https://example.cloudillo.net/share/${share.data.refId}`
// 3. Guest accesses the share (on their side)
const { token } = await fetch('/api/auth/access-token?ref=' + refId)
.then(r => r.json())
.then(r => r.data)
// 4. Guest uses token to access file
const file = await fetch('/api/files/f1~xyz789', {
headers: { 'Authorization': `Bearer ${token}` }
})
See Also
Shares API
Overview
The Shares API manages persistent file access grants. Shares allow file owners to grant read or write access to specific users or via links.
Shares vs References vs FSHR Actions
- Shares are persistent access grants stored on the file (managed here)
- References are shareable tokens/links (see References API)
- FSHR actions are federation-level file sharing events (see Actions API)
When you create a user share, the system automatically creates an FSHR action for federation.
Share Entry Fields
| Field |
Type |
Description |
id |
number |
Share entry ID |
resourceType |
string |
Resource type (F for file) |
resourceId |
string |
ID of the shared resource |
subjectType |
string |
Subject type: U (user), L (link), F (file) |
subjectId |
string |
ID of the recipient/subject |
permission |
string |
Permission level: R (read), W (write), A (admin) |
expiresAt |
string |
Expiration timestamp (ISO 8601) or null |
createdBy |
string |
Identity that created the share |
createdAt |
string |
Creation timestamp (ISO 8601) |
Endpoints
List Shares by Subject
List all shares where a given identity is the subject (recipient).
Authentication: Required
Query Parameters:
subjectId (required) - The subject identity to search for
subjectType (optional) - Subject type filter: U (user), L (link), F (file)
Response:
{
"data": [
{
"id": 1,
"resourceType": "F",
"resourceId": "b1~abc123",
"subjectType": "U",
"subjectId": "bob.cloudillo.net",
"permission": "R",
"expiresAt": null,
"createdBy": "alice.cloudillo.net",
"createdAt": "2025-01-15T10:30:00Z"
}
],
"time": "2025-01-15T10:30:00Z"
}
List File Shares
GET /api/files/{file_id}/shares
List all shares for a specific file.
Authentication: Required (must have write access to file)
Path Parameters:
Response:
{
"data": [
{
"id": 1,
"resourceType": "F",
"resourceId": "b1~abc123",
"subjectType": "U",
"subjectId": "bob.cloudillo.net",
"permission": "W",
"expiresAt": "2025-06-01T00:00:00Z",
"createdBy": "alice.cloudillo.net",
"createdAt": "2025-01-15T10:30:00Z"
}
],
"time": "2025-01-15T10:30:00Z"
}
Create Share
POST /api/files/{file_id}/shares
Grant access to a file by creating a share entry.
Authentication: Required (must have write access to file)
Path Parameters:
file_id - The file ID to share
Request Body:
{
"subjectType": "U",
"subjectId": "bob.cloudillo.net",
"permission": "R",
"expiresAt": "2025-06-01T00:00:00Z"
}
| Field |
Type |
Required |
Description |
subjectType |
string |
Yes |
U (user), L (link), or F (file) |
subjectId |
string |
Yes |
Non-empty ID of the recipient |
permission |
string |
Yes |
R (read), W (write), or A (admin) |
expiresAt |
string |
No |
Expiration timestamp (ISO 8601) |
Response (201 Created):
{
"data": {
"id": 2,
"resourceType": "F",
"resourceId": "b1~abc123",
"subjectType": "U",
"subjectId": "bob.cloudillo.net",
"permission": "R",
"expiresAt": "2025-06-01T00:00:00Z",
"createdBy": "alice.cloudillo.net",
"createdAt": "2025-01-15T10:30:00Z"
},
"time": "2025-01-15T10:30:00Z"
}
Info
When creating a user share (subjectType: "U"), the system automatically creates an FSHR action for federation delivery.
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subjectType":"U","subjectId":"bob.cloudillo.net","permission":"R"}' \
"https://cl-o.alice.cloudillo.net/api/files/b1~abc123/shares"
Delete Share
DELETE /api/files/{file_id}/shares/{share_id}
Revoke a file share.
Authentication: Required (must have write access to file)
Path Parameters:
file_id - The file ID
share_id - The share entry ID to delete
Response:
{
"data": null,
"time": "2025-01-15T10:30:00Z"
}
Info
When deleting a user share, the system automatically creates an FSHR action with subType: "DEL" for federation.
See Also
Identity Provider API
Overview
The Identity Provider (IDP) API enables identity management for Cloudillo’s DNS-based identity system. It allows identity providers to register, manage, and activate identities within their domain.
IDP Availability
IDP functionality must be enabled for the tenant. When disabled, all IDP endpoints return 404 Not Found.
Public Endpoints
These endpoints are available without authentication.
Get IDP Info
GET /api/idp/info
Get public information about this Identity Provider. Used by registration UIs to help users choose a provider.
Authentication: None
Response:
{
"data": {
"domain": "cloudillo.net",
"name": "Cloudillo",
"info": "Free identity hosting for the Cloudillo network",
"url": "https://cloudillo.net/identity"
},
"time": 1735000000,
"reqId": "req_abc123"
}
| Field |
Type |
Description |
domain |
string |
Provider domain (e.g., cloudillo.net) |
name |
string |
Display name of the provider |
info |
string |
Short description (pricing, terms) |
url |
string |
Optional URL for more information |
Check Availability
GET /api/idp/check-availability
Check if an identity tag is available for registration.
Authentication: Optional
Query Parameters:
| Parameter |
Type |
Required |
Description |
idTag |
string |
Yes |
Identity to check (e.g., alice.cloudillo.net) |
Response:
{
"data": {
"available": true,
"idTag": "alice.cloudillo.net"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Example:
const result = await api.idp.checkAvailability({
idTag: 'alice.cloudillo.net'
})
if (result.data.available) {
// Identity is available for registration
}
Activate Identity
POST /api/idp/activate
Activate an identity using a reference token. This is used when the identity owner activates their identity after the registrar has created it.
Authentication: None (uses reference token)
Request:
{
"refId": "ref_abc123def456"
}
| Field |
Type |
Required |
Description |
refId |
string |
Yes |
The activation reference ID |
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"status": "active",
"address": "cloudillo.example.com"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Identity Management
List Identities
GET /api/idp/identities
List identities managed by the authenticated user.
Authentication: Required
Query Parameters:
| Parameter |
Type |
Required |
Description |
email |
string |
No |
Filter by email (partial match) |
registrarIdTag |
string |
No |
Filter by registrar |
ownerIdTag |
string |
No |
Filter by owner |
status |
string |
No |
Filter by status: pending, active, suspended |
limit |
number |
No |
Maximum results to return |
offset |
number |
No |
Pagination offset |
Response:
{
"data": [
{
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"registrarIdTag": "admin.cloudillo.net",
"ownerIdTag": null,
"address": "cloudillo.example.com",
"addressUpdatedAt": 1735000000,
"status": "active",
"createdAt": 1734900000,
"updatedAt": 1735000000,
"expiresAt": 1766436000
}
],
"time": 1735000000,
"reqId": "req_abc123"
}
Example:
const identities = await api.idp.identities.list({
status: 'active',
limit: 20
})
Create Identity
POST /api/idp/identities
Create a new identity. The authenticated user becomes the registrar.
Authentication: Required
Request:
{
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"ownerIdTag": null,
"address": "cloudillo.example.com"
}
| Field |
Type |
Required |
Description |
idTag |
string |
Yes |
Identity tag (must be in registrar’s domain) |
email |
string |
No |
Email address (required if no ownerIdTag) |
ownerIdTag |
string |
No |
Owner identity for community-owned identities |
address |
string |
No |
Initial server address |
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"registrarIdTag": "admin.cloudillo.net",
"status": "pending",
"createdAt": 1735000000,
"updatedAt": 1735000000,
"expiresAt": 1766436000,
"apiKey": "clid_abc123..."
},
"time": 1735000000,
"reqId": "req_abc123"
}
API Key
The apiKey field is only returned during creation. Store it securely - it cannot be retrieved later.
Example:
const identity = await api.idp.identities.create({
idTag: 'alice.cloudillo.net',
email: 'alice@example.com'
})
console.log('Created identity:', identity.data.idTag)
console.log('API key:', identity.data.apiKey) // Store this!
Get Identity
GET /api/idp/identities/{id}
Get details of a specific identity.
Authentication: Required (must be owner or registrar)
Path Parameters:
| Parameter |
Type |
Description |
id |
string |
Identity tag (e.g., alice.cloudillo.net) |
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"registrarIdTag": "admin.cloudillo.net",
"ownerIdTag": null,
"address": "cloudillo.example.com",
"addressUpdatedAt": 1735000000,
"status": "active",
"createdAt": 1734900000,
"updatedAt": 1735000000,
"expiresAt": 1766436000
},
"time": 1735000000,
"reqId": "req_abc123"
}
Update Identity Address
PUT /api/idp/identities/{id}/address
Update the server address for an identity.
Authentication: Required (must be owner or registrar while pending)
Path Parameters:
| Parameter |
Type |
Description |
id |
string |
Identity tag |
Request:
{
"address": "new-server.example.com",
"autoAddress": false
}
| Field |
Type |
Required |
Description |
address |
string |
No |
New server address |
autoAddress |
boolean |
No |
If true and no address provided, use requester’s IP |
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"address": "new-server.example.com",
"addressUpdatedAt": 1735000000,
"status": "active"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Delete Identity
DELETE /api/idp/identities/{id}
Delete an identity. This action cannot be undone.
Authentication: Required (must be owner or registrar while pending)
Path Parameters:
| Parameter |
Type |
Description |
id |
string |
Identity tag to delete |
Response:
{
"data": {
"deleted": true,
"idTag": "alice.cloudillo.net"
},
"time": 1735000000,
"reqId": "req_abc123"
}
Permanent Deletion
Deleting an identity is permanent. The identity tag may be reused after deletion.
API Key Management
API keys provide programmatic access to identity operations.
Create API Key
POST /api/idp/api-keys
Create a new API key for the authenticated identity.
Authentication: Required
Request:
{
"name": "Server automation",
"expiresAt": 1766436000
}
| Field |
Type |
Required |
Description |
name |
string |
No |
Human-readable name |
expiresAt |
number |
No |
Expiration timestamp (Unix seconds) |
Response:
{
"data": {
"apiKey": {
"id": 123,
"idTag": "alice.cloudillo.net",
"keyPrefix": "clid_abc",
"name": "Server automation",
"createdAt": 1735000000,
"lastUsedAt": null,
"expiresAt": 1766436000
},
"plaintextKey": "clid_abc123def456ghi789..."
},
"time": 1735000000,
"reqId": "req_abc123"
}
Store Key Securely
The plaintextKey is only shown once during creation. Store it securely - it cannot be retrieved later.
List API Keys
GET /api/idp/api-keys
List API keys for the authenticated identity.
Authentication: Required
Query Parameters:
| Parameter |
Type |
Required |
Description |
limit |
number |
No |
Maximum results |
offset |
number |
No |
Pagination offset |
Response:
{
"data": [
{
"id": 123,
"idTag": "alice.cloudillo.net",
"keyPrefix": "clid_abc",
"name": "Server automation",
"createdAt": 1735000000,
"lastUsedAt": 1735050000,
"expiresAt": 1766436000
}
],
"time": 1735000000,
"reqId": "req_abc123"
}
Get API Key
GET /api/idp/api-keys/{id}
Get details of a specific API key.
Authentication: Required
Path Parameters:
| Parameter |
Type |
Description |
id |
number |
API key ID |
Response:
{
"data": {
"id": 123,
"idTag": "alice.cloudillo.net",
"keyPrefix": "clid_abc",
"name": "Server automation",
"createdAt": 1735000000,
"lastUsedAt": 1735050000,
"expiresAt": 1766436000
},
"time": 1735000000,
"reqId": "req_abc123"
}
Delete API Key
DELETE /api/idp/api-keys/{id}
Delete an API key. The key will immediately become invalid.
Authentication: Required
Path Parameters:
| Parameter |
Type |
Description |
id |
number |
API key ID to delete |
Response:
{
"data": {
"deleted": true,
"id": 123
},
"time": 1735000000,
"reqId": "req_abc123"
}
Authorization Model
IDP operations use a two-tier authorization model:
| Role |
Access |
Duration |
| Owner |
Full control |
Permanent |
| Registrar |
Create, view, update, delete |
Only while status is pending |
After an identity is activated (status changes from pending to active), the registrar loses control. Only the owner retains access.
Identity Status
| Status |
Description |
pending |
Created but not yet activated by owner |
active |
Activated and operational |
suspended |
Temporarily disabled by administrator |
See Also
The Tags API provides tag management and listing functionality for organizing files and content.
Endpoints
List all tags used by the current user.
Query Parameters:
prefix (optional) - Filter tags by prefix
withCounts (optional) - Include usage counts
limit (optional) - Maximum tags to return
Response:
{
"data": [
{
"tag": "project-alpha",
"count": 15
},
{
"tag": "important",
"count": 8
}
],
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/tags?prefix=proj-&withCounts=true"
File Tag Endpoints
Tags are primarily managed through the Files API.
Add Tag to File
PUT /api/files/{fileId}/tag/{tag}
Add a tag to a file.
Path Parameters:
fileId - File ID
tag - Tag name (URL-encoded)
Response:
{
"data": {
"tags": ["project-alpha", "important", "new-tag"]
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/files/file_abc123/tag/important"
Remove Tag from File
DELETE /api/files/{fileId}/tag/{tag}
Remove a tag from a file.
Path Parameters:
fileId - File ID
tag - Tag name (URL-encoded)
Response:
{
"data": {
"tags": ["project-alpha", "important"]
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/files/file_abc123/tag/old-tag"
Client SDK Usage
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({ idTag: 'alice.cloudillo.net', authToken: token })
// List all tags
const tags = await api.tags.list()
// List tags with prefix
const projectTags = await api.tags.list({ prefix: 'proj-' })
// Add tag to file
await api.files.addTag(fileId, 'important')
// Remove tag from file
await api.files.removeTag(fileId, 'old-tag')
Tag Naming Conventions
- Tags are case-sensitive
- Use lowercase with hyphens for consistency:
project-alpha, q1-2025
- Avoid special characters except hyphens and underscores
- Keep tags concise but descriptive
Filtering Files by Tag
// List files with a specific tag
const files = await api.files.list({ tag: 'important' })
// Multiple tags (all must match)
const files = await api.files.list({ tags: ['project-alpha', 'active'] })
See Also
Push Notifications API
Overview
The Push Notifications API enables Web Push notifications for Cloudillo. It uses the VAPID (Voluntary Application Server Identification) protocol to securely deliver notifications to users’ browsers when they’re offline.
Push notifications are sent when actions are received for the user while they are not connected via WebSocket.
Endpoints
Get VAPID Public Key
GET /api/auth/vapid
Get the VAPID public key for subscribing to push notifications. The key is automatically generated on first request if it doesn’t exist.
Authentication: Required
Response:
{
"vapidPublicKey": "BM5..."
}
| Field |
Type |
Description |
vapidPublicKey |
string |
Base64-encoded VAPID public key |
Example:
const response = await api.notifications.getVapidPublicKey()
const vapidPublicKey = response.vapidPublicKey
// Use with browser Push API
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey
})
Register Subscription
POST /api/notifications/subscription
Register a push notification subscription. The subscription is stored and used to send notifications when the user is offline.
Authentication: Required
Request:
{
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"expirationTime": null,
"keys": {
"p256dh": "BKgS...",
"auth": "Qs..."
}
}
}
| Field |
Type |
Required |
Description |
subscription.endpoint |
string |
Yes |
Push service endpoint URL |
subscription.expirationTime |
number |
No |
Expiration timestamp (Unix milliseconds) |
subscription.keys.p256dh |
string |
Yes |
P-256 public key (base64url) |
subscription.keys.auth |
string |
Yes |
Auth secret (base64url) |
Response:
| Field |
Type |
Description |
id |
number |
Subscription ID for later deletion |
Example:
// After getting VAPID public key and subscribing via Push API
const browserSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey
})
// Send subscription to server
const result = await api.notifications.subscribe({
subscription: browserSubscription.toJSON()
})
// Store subscription ID for later unsubscription
localStorage.setItem('pushSubscriptionId', result.id)
Unregister Subscription
DELETE /api/notifications/subscription/{id}
Remove a push notification subscription. The subscription will no longer receive notifications.
Authentication: Required
Path Parameters:
| Parameter |
Type |
Description |
id |
number |
Subscription ID to delete |
Response: 204 No Content
Example:
const subscriptionId = localStorage.getItem('pushSubscriptionId')
if (subscriptionId) {
await api.notifications.unsubscribe(subscriptionId)
localStorage.removeItem('pushSubscriptionId')
}
Complete Integration Example
// 1. Check if push is supported
if (!('PushManager' in window)) {
console.log('Push notifications not supported')
return
}
// 2. Get service worker registration
const registration = await navigator.serviceWorker.ready
// 3. Get VAPID public key from server
const { vapidPublicKey } = await api.notifications.getVapidPublicKey()
// 4. Subscribe via browser Push API
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
// 5. Send subscription to Cloudillo server
const result = await api.notifications.subscribe({
subscription: subscription.toJSON()
})
console.log('Push subscription registered with ID:', result.id)
// Helper function to convert base64 key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = atob(base64)
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}
Notification Settings
Users can control which notification types they receive through the Settings API:
| Setting |
Type |
Description |
notify.push.message |
boolean |
Receive notifications for new messages |
notify.push.mention |
boolean |
Receive notifications when mentioned |
notify.push.reaction |
boolean |
Receive notifications for reactions |
notify.push.connection |
boolean |
Receive notifications for connection requests |
notify.push.follow |
boolean |
Receive notifications for new followers |
Example:
// Disable reaction notifications
await api.settings.set('notify.push.reaction', false)
// Get current notification settings
const settings = await api.settings.list()
const pushSettings = Object.entries(settings)
.filter(([key]) => key.startsWith('notify.push.'))
Web Push Standards
The implementation follows these RFCs:
| RFC |
Title |
| RFC 8292 |
VAPID for Web Push |
| RFC 8188 |
Encrypted Content-Encoding for HTTP |
| RFC 8291 |
Message Encryption for Web Push |
See Also
Trash API
The Trash API manages soft-deleted files, allowing users to restore or permanently delete items.
Concepts
When a file is deleted via DELETE /api/files/{fileId}, it is moved to the trash rather than being permanently deleted. This allows users to:
- Recover accidentally deleted files
- Review deleted items before permanent removal
- Empty the trash to free up storage
Files remain in trash until explicitly restored or permanently deleted.
Endpoints
List Trashed Files
GET /api/files?parentId=__trash__
List all files currently in the trash.
Query Parameters:
parentId=__trash__ - Required to list trash
limit (optional) - Maximum files to return
Response:
{
"data": [
{
"fileId": "file_abc123",
"fileName": "document.pdf",
"contentType": "application/pdf",
"deletedAt": "2025-01-15T10:30:00Z",
"originalParentId": "folder_xyz"
}
],
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/files?parentId=__trash__&limit=50"
Restore File from Trash
POST /api/files/{fileId}/restore
Restore a file from the trash.
Path Parameters:
fileId - File ID to restore
Request Body:
{
"parentId": "folder_xyz"
}
If parentId is omitted or null, the file is restored to the root folder.
Response:
{
"data": {
"fileId": "file_abc123",
"parentId": "folder_xyz",
"restoredAt": "2025-01-15T11:00:00Z"
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"parentId": "folder_xyz"}' \
"https://cl-o.alice.cloudillo.net/api/files/file_abc123/restore"
Permanently Delete File
DELETE /api/files/{fileId}?permanent=true
Permanently delete a file from trash. The file must already be in trash.
Path Parameters:
fileId - File ID to permanently delete
Query Parameters:
permanent=true - Required for permanent deletion
Response:
{
"data": {
"fileId": "file_abc123",
"permanent": true
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/files/file_abc123?permanent=true"
Empty Trash
Permanently delete all files in the trash.
Response:
{
"data": {
"deletedCount": 15
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://cl-o.alice.cloudillo.net/api/trash"
Client SDK Usage
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({ idTag: 'alice.cloudillo.net', authToken: token })
// Move file to trash (soft delete)
await api.files.delete(fileId)
// List trashed files
const trashed = await api.trash.list()
// Restore a file
await api.files.restore(fileId, 'folder_xyz')
// Restore to root
await api.files.restore(fileId)
// Permanently delete a single file
await api.files.permanentDelete(fileId)
// Empty entire trash
const result = await api.trash.empty()
console.log(`Deleted ${result.deletedCount} files`)
File Lifecycle
┌──────────────┐ DELETE ┌──────────────┐
│ Active │ ───────────────>│ Trash │
│ File │ │ (soft) │
└──────────────┘ └──────────────┘
│
│ restore
│
┌───────────────────────┘
│
v
┌──────────────┐
│ Active │
│ File │
└──────────────┘
┌──────────────┐ DELETE?permanent ┌──────────────┐
│ Trash │ ──────────────────>│ Deleted │
│ (soft) │ │ (permanent) │
└──────────────┘ └──────────────┘
Automatic Trash Cleanup
Info
Automatic trash cleanup (retention period) is planned for future releases. Currently, files remain in trash indefinitely until manually restored or deleted.
See Also
Communities API
The Communities API enables creation and management of community profiles.
Concepts
Communities are special profile types that can have multiple members with different roles. They provide:
- Shared content feeds
- Member management with role hierarchy
- Community-specific settings
Roles form a hierarchy from least to most privileged:
| Role |
Level |
Description |
public |
0 |
Anyone (non-member) |
follower |
1 |
Following the community |
supporter |
2 |
Supporter/subscriber |
contributor |
3 |
Can create content |
moderator |
4 |
Can moderate content |
leader |
5 |
Full administrative access |
Endpoints
PUT /api/profiles/{idTag}
Create a new community profile.
Path Parameters:
idTag - Identity tag for the new community (e.g., mygroup.cloudillo.net)
Request Body:
{
"type": "community",
"name": "My Community",
"bio": "A community for enthusiasts",
"ownerIdTag": "alice.cloudillo.net"
}
Response:
{
"data": {
"idTag": "mygroup.cloudillo.net",
"name": "My Community",
"type": "community",
"bio": "A community for enthusiasts",
"createdAt": "2025-01-15T10:30:00Z"
},
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"community","name":"My Community","ownerIdTag":"alice.cloudillo.net"}' \
"https://cl-o.alice.cloudillo.net/api/profiles/mygroup.cloudillo.net"
POST /api/profiles/verify
Check if a community identity is available.
Request Body:
{
"type": "community",
"idTag": "mygroup.cloudillo.net"
}
Response:
{
"data": {
"available": true,
"errors": [],
"serverAddresses": ["192.168.1.100"]
},
"time": 1705315800,
"req_id": "req_xyz"
}
If not available:
{
"data": {
"available": false,
"errors": ["E-PROFILE-EXISTS"],
"serverAddresses": []
}
}
Client SDK Usage
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({ idTag: 'alice.cloudillo.net', authToken: token })
// Verify community name is available
const verification = await api.profile.verify({
type: 'community',
idTag: 'mygroup.cloudillo.net'
})
if (verification.available) {
// Create the community
const community = await api.communities.create('mygroup.cloudillo.net', {
type: 'community',
name: 'My Community',
ownerIdTag: 'alice.cloudillo.net'
})
}
Member Management
Member roles are managed through the Profiles API.
Update Member Role
// As community leader/moderator
await api.profiles.adminUpdate('member.cloudillo.net', {
roles: ['contributor']
})
const members = await api.profiles.list({
community: 'mygroup.cloudillo.net',
role: 'contributor'
})
Role-Based Permissions
Use the ROLE_LEVELS constant from @cloudillo/types for permission checks:
import { ROLE_LEVELS, CommunityRole } from '@cloudillo/types'
function hasPermission(userRole: CommunityRole, requiredRole: CommunityRole): boolean {
return ROLE_LEVELS[userRole] >= ROLE_LEVELS[requiredRole]
}
// Check if user can moderate
if (hasPermission(userRole, 'moderator')) {
// Allow moderation actions
}
See Also
Admin API
Overview
The Admin API provides administrative operations for system administrators.
Warning
Admin endpoints require elevated privileges. These operations are restricted to users with the SADM (system admin) role.
Endpoints
List Tenants
List all tenants (identities) managed by this server.
Authentication: Required (admin role)
Query Parameters:
limit - Maximum results (default: 20)
offset - Skip N results for pagination
Response:
{
"data": [
{
"tnId": 12345,
"idTag": "alice.cloudillo.net",
"name": "Alice",
"type": "person",
"profilePic": "b1~abc123",
"createdAt": "2025-01-01T00:00:00Z"
},
{
"tnId": 12346,
"idTag": "bob.cloudillo.net",
"name": "Bob",
"type": "person",
"createdAt": "2025-01-02T00:00:00Z"
}
],
"time": "2025-01-15T10:30:00Z"
}
Example:
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://cl-o.admin.cloudillo.net/api/admin/tenants?limit=50"
Send Password Reset
POST /api/admin/tenants/{id_tag}/password-reset
Send a password reset email to a tenant.
Authentication: Required (admin role)
Path Parameters:
id_tag - Identity tag of the tenant
Response:
{
"data": {
"sent": true
},
"time": "2025-01-15T10:30:00Z"
}
Example:
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://cl-o.admin.cloudillo.net/api/admin/tenants/alice.cloudillo.net/password-reset"
Send Test Email
POST /api/admin/email/test
Send a test email to verify SMTP configuration.
Authentication: Required (admin role)
Request Body:
{
"to": "admin@example.com"
}
Response:
{
"data": {
"sent": true
},
"time": "2025-01-15T10:30:00Z"
}
Example:
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"admin@example.com"}' \
"https://cl-o.admin.cloudillo.net/api/admin/email/test"
Update Profile (Admin)
PATCH /api/admin/profiles/{id_tag}
Admin update of a profile (roles, status, ban metadata).
Authentication: Required (admin role)
Path Parameters:
id_tag - Identity tag of the profile to update
Request Body:
{
"name": "Updated Name",
"status": "Suspended",
"roles": ["user", "moderator"],
"banExpiresAt": "2025-02-01T00:00:00Z",
"banReason": "Terms of service violation"
}
Profile Status Values:
| Status |
Description |
Active |
Normal active account |
Trusted |
Verified/trusted account |
Blocked |
Blocked from interactions |
Muted |
Content hidden from feeds |
Suspended |
Account suspended (temporary) |
Banned |
Account banned (permanent) |
Response:
{
"data": {
"idTag": "user.cloudillo.net",
"name": "Updated Name",
"status": "Suspended",
"roles": ["user", "moderator"]
},
"time": "2025-01-15T10:30:00Z"
}
Proxy Site Management
Manage reverse proxy sites for hosting custom domains.
List Proxy Sites
GET /api/admin/proxy-sites
List all configured proxy sites.
Authentication: Required (admin role)
Response:
{
"data": [
{
"siteId": 1,
"domain": "docs.example.com",
"backendUrl": "http://localhost:8080",
"status": "A",
"type": "basic",
"certExpiresAt": "2025-06-01T00:00:00Z",
"config": {},
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
}
],
"time": "2025-01-15T10:30:00Z"
}
Create Proxy Site
POST /api/admin/proxy-sites
Create a new proxy site configuration.
Authentication: Required (admin role)
Request Body:
{
"domain": "docs.example.com",
"backendUrl": "http://localhost:8080",
"type": "basic",
"config": {}
}
| Field |
Type |
Required |
Description |
domain |
string |
Yes |
Domain name for the proxy site |
backendUrl |
string |
Yes |
Backend URL to proxy requests to |
type |
string |
No |
Proxy type: basic (default) or advanced |
config |
object |
No |
Additional proxy configuration |
Response (201 Created): Returns the created ProxySite object.
Get Proxy Site
GET /api/admin/proxy-sites/{site_id}
Get details of a specific proxy site.
Authentication: Required (admin role)
Path Parameters:
site_id - The proxy site ID
Update Proxy Site
PATCH /api/admin/proxy-sites/{site_id}
Update a proxy site configuration.
Authentication: Required (admin role)
Path Parameters:
site_id - The proxy site ID
Request Body:
{
"backendUrl": "http://localhost:9090",
"status": "A",
"config": {}
}
| Field |
Type |
Description |
backendUrl |
string |
Updated backend URL |
status |
string |
A (active) or D (disabled) |
type |
string |
Proxy type |
config |
object |
Updated configuration |
Delete Proxy Site
DELETE /api/admin/proxy-sites/{site_id}
Delete a proxy site configuration.
Authentication: Required (admin role)
Path Parameters:
site_id - The proxy site ID
Response: 204 No Content
Renew Proxy Site Certificate
POST /api/admin/proxy-sites/{site_id}/renew-cert
Trigger TLS certificate renewal for a proxy site.
Authentication: Required (admin role)
Path Parameters:
site_id - The proxy site ID
POST /api/admin/invite-community
Send an invitation to a community to join the server.
Authentication: Required (admin role)
Request Body:
{
"targetIdTag": "community.example.com",
"expiresInDays": 30,
"message": "Join our platform!"
}
| Field |
Type |
Required |
Description |
targetIdTag |
string |
Yes |
Identity tag of the community to invite |
expiresInDays |
number |
No |
Invitation expiry in days (default: 30) |
message |
string |
No |
Optional invitation message |
Response:
{
"data": {
"refId": "ref_abc123",
"inviteUrl": "https://example.com/invite/ref_abc123",
"targetIdTag": "community.example.com",
"expiresAt": 1740000000
},
"time": "2025-01-15T10:30:00Z"
}
Client SDK Usage
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({ idTag: 'admin.cloudillo.net', authToken: adminToken })
// List all tenants
const tenants = await api.admin.listTenants({ limit: 100 })
// Search tenants
const results = await api.admin.listTenants({ q: 'alice' })
// Send password reset
await api.admin.sendPasswordReset('alice.cloudillo.net')
// Send test email
await api.admin.sendTestEmail('admin@example.com')
// Suspend a user
await api.profiles.adminUpdate('baduser.cloudillo.net', {
status: 'S',
ban_reason: 'Spam'
})
Security Considerations
- Admin endpoints require admin role authentication
- All admin actions are logged for audit purposes
- Password reset emails are rate-limited
- Suspension requires a reason for accountability
See Also
IDP Management API
The IDP (Identity Provider) Management API enables identity provider administrators to manage identities and API keys for their hosted identities.
Info
This API is for identity provider administrators who host identities for other users (e.g., cloudillo.net service). For end-user identity operations, see IDP API.
Endpoints
List Managed Identities
List all identities managed by this identity provider.
Query Parameters:
q (optional) - Search query
status (optional) - Filter by status
cursor (optional) - Pagination cursor
limit (optional) - Maximum results
Response:
{
"data": [
{
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"status": "active",
"createdAt": "2025-01-01T00:00:00Z",
"ownerIdTag": null,
"dyndns": true
},
{
"idTag": "bob.cloudillo.net",
"email": "bob@example.com",
"status": "active",
"createdAt": "2025-01-02T00:00:00Z",
"ownerIdTag": "alice.cloudillo.net",
"dyndns": false
}
],
"time": 1705315800,
"req_id": "req_xyz"
}
Example:
curl -H "Authorization: Bearer $IDP_TOKEN" \
"https://cl-o.cloudillo.net/api/idp/identities?limit=50"
Create Identity
Create a new identity under this provider.
Request Body:
{
"idTag": "newuser.cloudillo.net",
"email": "newuser@example.com",
"ownerIdTag": "alice.cloudillo.net",
"createApiKey": true,
"apiKeyName": "Initial Key"
}
Response:
{
"data": {
"idTag": "newuser.cloudillo.net",
"email": "newuser@example.com",
"status": "pending",
"createdAt": "2025-01-15T10:30:00Z",
"apiKey": "clak_abc123xyz..."
},
"time": 1705315800,
"req_id": "req_xyz"
}
Warning
The apiKey is only returned once when createApiKey: true. Store it securely.
Example:
curl -X POST -H "Authorization: Bearer $IDP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"idTag":"newuser.cloudillo.net","email":"newuser@example.com","createApiKey":true}' \
"https://cl-o.cloudillo.net/api/idp/identities"
Get Identity Details
GET /api/idp/identities/{idTag}
Get details for a specific managed identity.
Path Parameters:
idTag - Identity tag (URL-encoded)
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"email": "alice@example.com",
"status": "active",
"createdAt": "2025-01-01T00:00:00Z",
"lastLoginAt": "2025-01-15T10:30:00Z",
"ownerIdTag": null,
"dyndns": true
},
"time": 1705315800,
"req_id": "req_xyz"
}
Update Identity
PATCH /api/idp/identities/{idTag}
Update identity settings.
Path Parameters:
idTag - Identity tag (URL-encoded)
Request Body:
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"dyndns": true
},
"time": 1705315800,
"req_id": "req_xyz"
}
Update Identity Address
PUT /api/idp/identities/{idTag}/address
Update the DNS address mapping for a managed identity. This is used for dynamic DNS updates when self-hosting.
Path Parameters:
idTag - Identity tag (URL-encoded)
Request Body:
{
"address": "192.168.1.100"
}
Response:
{
"data": {
"idTag": "alice.cloudillo.net",
"address": "192.168.1.100"
},
"time": 1705315800,
"req_id": "req_xyz"
}
Delete Identity
DELETE /api/idp/identities/{idTag}
Delete a managed identity.
Path Parameters:
idTag - Identity tag (URL-encoded)
Response:
{
"data": null,
"time": 1705315800,
"req_id": "req_xyz"
}
List API Keys for Identity
GET /api/idp/api-keys?idTag={idTag}
List API keys for a specific managed identity.
Query Parameters:
idTag - Identity tag to list keys for
Response:
{
"data": [
{
"keyId": 1,
"name": "Production Key",
"createdAt": "2025-01-01T00:00:00Z",
"lastUsedAt": "2025-01-15T10:30:00Z"
}
],
"time": 1705315800,
"req_id": "req_xyz"
}
Create API Key for Identity
Create a new API key for a managed identity.
Request Body:
{
"idTag": "alice.cloudillo.net",
"name": "New API Key"
}
Response:
{
"data": {
"keyId": 2,
"name": "New API Key",
"apiKey": "clak_newkey123...",
"createdAt": "2025-01-15T10:30:00Z"
},
"time": 1705315800,
"req_id": "req_xyz"
}
Get API Key Details
GET /api/idp/api-keys/{keyId}
Get details of a specific API key.
Path Parameters:
Response:
{
"data": {
"keyId": 1,
"name": "Production Key",
"createdAt": "2025-01-01T00:00:00Z",
"lastUsedAt": "2025-01-15T10:30:00Z"
},
"time": 1705315800,
"req_id": "req_xyz"
}
Revoke API Key
DELETE /api/idp/api-keys/{keyId}?idTag={idTag}
Revoke an API key for a managed identity.
Path Parameters:
Query Parameters:
idTag - Identity tag the key belongs to
Response:
{
"data": null,
"time": 1705315800,
"req_id": "req_xyz"
}
Client SDK Usage
import { createApiClient } from '@cloudillo/core'
const api = createApiClient({ idTag: 'cloudillo.net', authToken: idpToken })
// List managed identities
const identities = await api.idpManagement.listIdentities({ limit: 100 })
// Create new identity with API key
const newIdentity = await api.idpManagement.createIdentity({
idTag: 'newuser.cloudillo.net',
email: 'newuser@example.com',
createApiKey: true,
apiKeyName: 'Initial Key'
})
console.log('API Key:', newIdentity.apiKey) // Store securely!
// Get identity details
const identity = await api.idpManagement.getIdentity('alice.cloudillo.net')
// Update identity settings
await api.idpManagement.updateIdentity('alice.cloudillo.net', { dyndns: true })
// Delete identity
await api.idpManagement.deleteIdentity('olduser.cloudillo.net')
// Manage API keys
const keys = await api.idpManagement.listApiKeys('alice.cloudillo.net')
const newKey = await api.idpManagement.createApiKey({
idTag: 'alice.cloudillo.net',
name: 'Backup Key'
})
await api.idpManagement.deleteApiKey(keyId, 'alice.cloudillo.net')
Use Cases
Provisioning New Users
async function provisionUser(email: string, subdomain: string) {
const idTag = `${subdomain}.cloudillo.net`
// Create identity with initial API key
const result = await api.idpManagement.createIdentity({
idTag,
email,
createApiKey: true,
apiKeyName: 'Setup Key'
})
// Send setup instructions with API key
await sendSetupEmail(email, {
idTag,
apiKey: result.apiKey
})
return result
}
Automating Identity Management
// Deactivate inactive users
const identities = await api.idpManagement.listIdentities()
for (const identity of identities.data) {
const daysSinceLogin = daysSince(identity.lastLoginAt)
if (daysSinceLogin > 365) {
await api.idpManagement.deleteIdentity(identity.idTag)
}
}
See Also
WebSocket API
Cloudillo provides three WebSocket endpoints for real-time features.
| Endpoint |
Purpose |
Protocol |
/ws/bus |
Notifications and direct messaging |
JSON ({ id, cmd, data }) |
/ws/rtdb/{file_id} |
Real-time database sync |
JSON ({ id, type, ... }) |
/ws/crdt/{doc_id} |
Collaborative document editing |
Binary (Yjs sync protocol) |
Authentication
Tokens are passed as a query parameter:
wss://server.com/ws/bus?token=eyJhbGc...
wss://server.com/ws/rtdb/file_123?token=eyJhbGc...
wss://server.com/ws/crdt/doc_123?token=eyJhbGc...&access=write
Additional query parameters for RTDB and CRDT:
| Parameter |
Values |
Description |
access |
read, write |
Force access level (default: determined by permissions) |
via |
file ID |
Container file ID for embedded access (caps access by share entry) |
Info
The /ws/bus endpoint requires authentication – unauthenticated connections are rejected with close code 4401. The RTDB and CRDT endpoints support guest (unauthenticated) access with read-only permissions for public files.
WebSocket close codes
| Code |
Meaning |
4400 |
Invalid store ID format |
4401 |
Unauthorized (authentication required) |
4403 |
Access denied or write access denied |
4404 |
File/document not found |
4409 |
Store type mismatch (e.g. RTDB endpoint for a CRDT file) |
4500 |
Internal server error |
Message bus (/ws/bus)
The bus provides direct user-to-user messaging and notifications. All messages use the format { id, cmd, data }.
Client → Server:
cmd |
Description |
ping |
Keepalive; server responds with ack "pong" |
ACTION |
Send an action; server responds with ack "ok" |
| Any other |
Custom command (presence, typing, etc.); server acks with "ok" |
Server → Client:
Messages from other users are forwarded with the same { id, cmd, data } format. The bus does not use channels or subscriptions – it’s a direct messaging system where the server forwards relevant messages to registered users.
Client-side connection
The openMessageBus() function from @cloudillo/core returns a raw WebSocket:
import { openMessageBus } from '@cloudillo/core'
const ws = openMessageBus({ idTag: 'alice', authToken: token })
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
console.log('Command:', msg.cmd, 'Data:', msg.data)
}
// Send a command
ws.send(JSON.stringify({ id: '1', cmd: 'ping', data: {} }))
Real-time database (/ws/rtdb/{file_id})
Real-time synchronization of structured data. All messages use the format { id, type, ...payload }. See RTDB for the client library documentation.
Client → server messages
| Type |
Key fields |
Description |
transaction |
operations: [{type, path, data}] |
Atomic batch of create/update/replace/delete operations |
query |
path, filter?, sort?, limit?, offset?, aggregate? |
Query documents with optional filtering and aggregation |
get |
path |
Get a single document |
subscribe |
path, filter?, aggregate? |
Start real-time change notifications for a path |
unsubscribe |
subscriptionId |
Stop receiving change notifications |
lock |
path, mode |
Lock a document ("soft" or "hard") |
unlock |
path |
Release a document lock |
ping |
– |
Keepalive |
Server → client messages
| Type |
Key fields |
Description |
ack |
status, timestamp |
Acknowledges a transaction/command |
transactionResult |
results: [{ref?, id}] |
Per-operation results from a transaction |
queryResult |
data: [...] |
Query results |
getResult |
data |
Single document result |
subscribeResult |
subscriptionId, data |
Initial subscription data |
change |
subscriptionId, event: {action, path, data?} |
Real-time change notification |
lockResult |
locked, holder?, mode? |
Lock operation result |
pong |
– |
Keepalive response |
error |
code, message |
Error response |
Transaction operations
Each operation in a transaction has a type and a path:
| Operation |
Description |
create |
Create a document; returns generated ID. Supports ref for cross-referencing within the transaction |
update |
Shallow merge (Firebase-style) with existing document |
replace |
Full document replacement (no merge) |
delete |
Delete a document |
All operations in a transaction are atomic – if any fails, the entire transaction rolls back. Operations support computed values ($op, $fn, $query) and reference substitution (${$ref} patterns) for creating related documents in a single transaction.
Change event actions
The event.action field in change notifications can be: create, update, delete, lock, unlock, or ready.
Client-side connection
The openRTDB() function from @cloudillo/core returns a raw WebSocket. For higher-level usage, use the @cloudillo/rtdb client library:
import { createRtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
const db = createRtdbClient({
dbId: fileId,
auth: { getToken: () => bus.accessToken },
serverUrl: getRtdbUrl(bus.idTag!, fileId, bus.accessToken!)
})
Store files
RTDB supports auto-created store files using the pattern s~{app_id} (e.g. s~taskillo). These are created automatically on first WebSocket connection, providing persistent app-specific data storage without manual file creation.
Collaborative documents (/ws/crdt/{doc_id})
CRDT synchronization using the Yjs binary protocol. See CRDT for full documentation.
Protocol
The endpoint uses the y-websocket binary protocol:
| Message type |
Code |
Description |
MSG_SYNC |
0 |
Sync protocol (SyncStep1, SyncStep2, Update) |
MSG_AWARENESS |
1 |
User presence and cursor updates |
The sync flow:
- Client sends SyncStep1 (state vector)
- Server responds with SyncStep2 (missing updates)
- Both sides exchange Updates incrementally as edits happen
- Awareness messages broadcast cursor positions and user presence
Read-only connections can receive sync and awareness data but cannot send Update messages.
Client-side connection
Use openYDoc() from @cloudillo/crdt, which handles authentication, client ID reuse, offline caching, and token refresh automatically:
import { openYDoc } from '@cloudillo/crdt'
import * as Y from 'yjs'
const yDoc = new Y.Doc()
const { provider, persistence, offlineCached } = await openYDoc(yDoc, 'ownerTag:docId')
// Access shared types
const yText = yDoc.getText('content')
// Awareness is available via provider.awareness
Note
openYDoc() automatically handles WebSocket close codes: on 4401 (unauthorized) it requests a fresh token from the shell and reconnects. On other 44xx errors it stops reconnection and notifies the shell via bus.notifyError().
Connection lifecycle
All three endpoints share common behaviors:
- Heartbeat: Server sends WebSocket ping frames every 30 seconds
- Multi-tab support: Each connection gets a unique
conn_id; multiple connections per user are supported
- Cleanup: On disconnect, locks are released (RTDB), subscriptions cancelled, and user unregistered (bus)
- Activity tracking: File access and modification times are recorded (throttled to 60-second intervals)
See also
- RTDB – real-time database client library
- CRDT – collaborative editing with Yjs
- Authentication – token management
RTDB (Real-Time Database)
The Cloudillo RTDB provides a Firebase-like real-time database with TypeScript support.
RTDB vs CRDT
RTDB is best for structured data with queries (todos, settings, lists). For collaborative document editing where multiple users edit simultaneously, see CRDT. Compare both in Data Storage & Access.
Installation
Quick Start
import { getAppBus } from '@cloudillo/core'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
// Initialize Cloudillo
const bus = getAppBus()
await bus.init('my-app')
// Create RTDB client
const rtdb = new RtdbClient({
dbId: 'my-database-id',
auth: {
getToken: () => bus.accessToken
},
serverUrl: getRtdbUrl(bus.idTag!, 'my-database-id', bus.accessToken!)
})
// Connect to the database
await rtdb.connect()
// Get collection reference
const todos = rtdb.collection('todos')
// Create document
const batch = rtdb.batch()
batch.create(todos, {
title: 'Learn Cloudillo RTDB',
completed: false,
createdAt: Date.now()
})
await batch.commit()
// Subscribe to changes
todos.onSnapshot((snapshot) => {
console.log('Todos:', snapshot.docs.map(doc => doc.data()))
})
RtdbClient Constructor
interface RtdbClientOptions {
dbId: string // Database/file ID
auth: {
getToken: () => string | undefined | Promise<string | undefined>
}
serverUrl: string // WebSocket URL
options?: {
enableCache?: boolean // Default: false
reconnect?: boolean // Default: true
reconnectDelay?: number // Default: 1000ms
maxReconnectDelay?: number // Default: 30000ms
debug?: boolean // Default: false
}
}
Example with all options:
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
const rtdb = new RtdbClient({
dbId: 'my-database-id',
auth: {
// Token provider function - called when connection needs auth
getToken: async () => {
// Can be sync or async
return bus.accessToken
}
},
serverUrl: getRtdbUrl(bus.idTag!, 'my-database-id', bus.accessToken!),
options: {
enableCache: true, // Enable local caching
reconnect: true, // Auto-reconnect on disconnect
reconnectDelay: 1000, // Initial reconnect delay
maxReconnectDelay: 30000, // Max reconnect delay (exponential backoff)
debug: false // Enable debug logging
}
})
Connection Management
// Connect to database
await rtdb.connect()
// Disconnect
await rtdb.disconnect()
// Check connection status
if (rtdb.isConnected()) {
console.log('Connected')
}
// Diagnostics
console.log('Pending requests:', rtdb.getPendingRequests())
console.log('Active subscriptions:', rtdb.getActiveSubscriptions())
Core Concepts
Collections
Collections are groups of documents, similar to tables in SQL.
const users = rtdb.collection('users')
const posts = rtdb.collection('posts')
const comments = rtdb.collection('comments')
// Typed collections
interface Todo {
title: string
completed: boolean
createdAt: number
}
const todos = rtdb.collection<Todo>('todos')
Documents
Documents are individual records accessed by path.
// Reference a document by path
const userDoc = rtdb.ref('users/alice')
// Get document data
const snapshot = await userDoc.get()
if (snapshot.exists) {
console.log(snapshot.data())
}
CRUD Operations
Create
Use batch operations to create documents:
const todos = rtdb.collection('todos')
const batch = rtdb.batch()
// Create with auto-generated ID
batch.create(todos, {
title: 'New task',
completed: false
})
// Create with ref for tracking
batch.create(todos, {
title: 'Another task',
completed: false
}, { ref: 'task-ref' })
// Commit returns results with IDs
const results = await batch.commit()
console.log('Created IDs:', results.map(r => r.id))
Read
// Get single document
const doc = rtdb.ref('todos/task_123')
const snapshot = await doc.get()
if (snapshot.exists) {
console.log(snapshot.data())
}
// Query collection
const todos = rtdb.collection('todos')
const results = await todos.get()
results.docs.forEach(doc => {
console.log(doc.id, doc.data())
})
Update
const batch = rtdb.batch()
const todoRef = rtdb.ref('todos/task_123')
// Partial update
batch.update(todoRef, {
completed: true
})
await batch.commit()
Delete
const batch = rtdb.batch()
const todoRef = rtdb.ref('todos/task_123')
batch.delete(todoRef)
await batch.commit()
Queries
Query Options
interface QueryOptions {
filter?: {
equals?: Record<string, any>
notEquals?: Record<string, any>
greaterThan?: Record<string, any>
lessThan?: Record<string, any>
greaterThanOrEqual?: Record<string, any>
lessThanOrEqual?: Record<string, any>
in?: Record<string, any[]>
notIn?: Record<string, any[]>
arrayContains?: Record<string, any>
arrayContainsAny?: Record<string, any[]>
arrayContainsAll?: Record<string, any[]>
}
sort?: Array<{ field: string; ascending: boolean }>
limit?: number
offset?: number
}
You can also build queries using the chainable .where() builder:
type WhereFilterOp =
| '==' | '!='
| '<' | '>'
| '<=' | '>='
| 'in' | 'not-in'
| 'array-contains'
| 'array-contains-any'
| 'array-contains-all'
collection.where(field: string, op: WhereFilterOp, value: any)
Filtering
const todos = rtdb.collection('todos')
// Filter with options object
const incomplete = await todos.query({
filter: {
equals: { completed: false }
}
})
// Filter with chainable .where() builder
const incomplete2 = await todos
.where('completed', '==', false)
.get()
Filter Operators
// Equality
const active = await todos.where('status', '==', 'active').get()
const notDone = await todos.where('status', '!=', 'done').get()
// Comparison
const highPriority = await todos.where('priority', '>', 3).get()
const recent = await todos.where('createdAt', '>=', lastWeek).get()
// Set membership
const selected = await todos
.where('status', 'in', ['active', 'pending'])
.get()
const excluded = await todos
.where('status', 'not-in', ['archived', 'deleted'])
.get()
// Array filters
const tagged = await todos
.where('tags', 'array-contains', 'urgent')
.get()
const anyTag = await todos
.where('tags', 'array-contains-any', ['urgent', 'important'])
.get()
const allTags = await todos
.where('tags', 'array-contains-all', ['frontend', 'bug'])
.get()
// Chain multiple filters
const filtered = await todos
.where('completed', '==', false)
.where('priority', '>', 2)
.get()
Sorting
// Sort ascending
const sorted = await todos.query({
sort: [{ field: 'createdAt', ascending: true }]
})
// Sort descending
const newest = await todos.query({
sort: [{ field: 'createdAt', ascending: false }]
})
// Multiple sorts
const prioritized = await todos.query({
sort: [
{ field: 'priority', ascending: false },
{ field: 'createdAt', ascending: true }
]
})
// Limit results
const first10 = await todos.query({
limit: 10
})
// Pagination with offset
const page2 = await todos.query({
limit: 20,
offset: 20
})
Combined Queries
const results = await todos.query({
filter: {
equals: { completed: false }
},
sort: [{ field: 'createdAt', ascending: false }],
limit: 10
})
Real-Time Subscriptions
Collection Subscriptions
const todos = rtdb.collection('todos')
// Subscribe to all documents
const unsubscribe = todos.onSnapshot((snapshot) => {
console.log('Total todos:', snapshot.size)
snapshot.docs.forEach(doc => {
console.log(doc.id, doc.data())
})
// Track changes
const changes = snapshot.docChanges()
changes.forEach(change => {
switch (change.type) {
case 'added':
console.log('New:', change.doc.id)
break
case 'modified':
console.log('Updated:', change.doc.id)
break
case 'removed':
console.log('Deleted:', change.doc.id)
break
}
})
})
// Unsubscribe later
unsubscribe()
Document Subscriptions
const todoRef = rtdb.ref('todos/task_123')
const unsubscribe = todoRef.onSnapshot((snapshot) => {
if (snapshot.exists) {
console.log('Todo updated:', snapshot.data())
} else {
console.log('Todo deleted')
}
})
Filtered Subscriptions
// Using options object
const unsubscribe = todos.subscribe({
filter: {
equals: { completed: false }
}
}, (snapshot) => {
console.log('Incomplete todos:', snapshot.size)
})
// Using .where() builder
const unsubscribe2 = todos
.where('completed', '==', false)
.onSnapshot((snapshot) => {
console.log('Incomplete todos:', snapshot.size)
})
Document Locking
Lock documents for exclusive or advisory editing access.
Lock Modes
soft — Advisory lock. Other clients can still write, but are notified that the document is locked.
hard — Enforced lock. The server rejects writes from other clients while the lock is held.
Locking and Unlocking
const docRef = rtdb.ref('todos/task_123')
// Acquire a soft (advisory) lock
const result = await docRef.lock('soft')
// result: { locked: true }
// Acquire a hard (exclusive) lock
const result2 = await docRef.lock('hard')
// result: { locked: true }
// If already locked by another client
const result3 = await docRef.lock('hard')
// result: { locked: false, holder: 'bob@example.com', mode: 'hard' }
// Release the lock
await docRef.unlock()
Lock Result
interface LockResult {
locked: boolean // Whether the lock was acquired
holder?: string // Identity of current lock holder (if denied)
mode?: 'soft' | 'hard' // Lock mode of existing lock (if denied)
}
Lock Events
Listen for lock changes on a document using the onLock callback in snapshot options:
const docRef = rtdb.ref('todos/task_123')
const unsubscribe = docRef.onSnapshot({
onLock: (event) => {
if (event.action === 'lock') {
console.log(`Locked by ${event.holder} (${event.mode})`)
} else if (event.action === 'unlock') {
console.log('Document unlocked')
}
}
}, (snapshot) => {
console.log('Data:', snapshot.data())
})
Example: Exclusive Editing
async function startEditing(docRef: DocumentRef) {
const result = await docRef.lock('hard')
if (!result.locked) {
alert(`Document is locked by ${result.holder}`)
return false
}
// Edit the document...
return true
}
async function stopEditing(docRef: DocumentRef) {
await docRef.unlock()
}
Info
Locks have a TTL (time-to-live) and expire automatically if the client disconnects or fails to renew them. This prevents permanently locked documents from abandoned sessions.
Aggregate Queries
Perform server-side aggregations on collections.
Aggregate API
interface AggregateOptions {
groupBy?: string // Field to group results by
ops: AggregateOp[] // Aggregation operations
}
type AggregateOp = 'sum' | 'avg' | 'min' | 'max'
interface AggregateGroupEntry {
group: any // Value of the groupBy field
count: number // Number of documents in the group
[key: string]: any // Aggregate results (e.g., sum_hours, avg_hours)
}
interface AggregateSnapshot {
groups: AggregateGroupEntry[]
}
Basic Aggregation
const todos = rtdb.collection('todos')
// Aggregate with filters
const result = await todos
.where('completed', '==', false)
.aggregate({
groupBy: 'status',
ops: ['sum', 'avg']
})
.get()
result.groups.forEach(group => {
console.log(`${group.group}: ${group.count} items`)
})
Real-Time Aggregate Subscriptions
Aggregate queries support real-time updates via onSnapshot:
const unsubscribe = todos
.where('completed', '==', false)
.aggregate({
groupBy: 'status',
ops: ['sum']
})
.onSnapshot((snapshot: AggregateSnapshot) => {
snapshot.groups.forEach(group => {
console.log(`${group.group}: ${group.count} items`)
})
})
Example: Task Dashboard
const tasks = rtdb.collection('tasks')
// Group tasks by status with count and total estimated hours
const unsubscribe = tasks
.aggregate({
groupBy: 'status',
ops: ['sum', 'avg']
})
.onSnapshot((snapshot) => {
snapshot.groups.forEach(({ group, count, sum_hours, avg_hours }) => {
console.log(`${group}: ${count} tasks, ${sum_hours}h total, ${avg_hours}h avg`)
})
// Output:
// todo: 12 tasks, 36h total, 3h avg
// in_progress: 5 tasks, 20h total, 4h avg
// done: 28 tasks, 84h total, 3h avg
})
Batch Operations
Perform multiple operations atomically:
const todos = rtdb.collection('todos')
const batch = rtdb.batch()
// Create multiple
batch.create(todos, { title: 'Task 1', completed: false })
batch.create(todos, { title: 'Task 2', completed: false })
// Update existing
batch.update(rtdb.ref('todos/task_123'), { completed: true })
// Delete
batch.delete(rtdb.ref('todos/task_456'))
// Commit all operations atomically
const results = await batch.commit()
console.log('Batch results:', results)
BatchResult:
interface BatchResult {
ref?: string // Reference ID if provided
id?: string // Generated document ID
}
Indexes
Create indexes for efficient queries:
// Create index on a field
await rtdb.createIndex('todos', 'createdAt')
await rtdb.createIndex('todos', 'completed')
TypeScript Support
Full type safety with generics:
interface Todo {
title: string
completed: boolean
createdAt: number
tags?: string[]
}
const todos = rtdb.collection<Todo>('todos')
// TypeScript knows the shape
const snapshot = await todos.get()
snapshot.docs.forEach(doc => {
const data = doc.data()
console.log(data.title) // string
console.log(data.completed) // boolean
// @ts-error: Property 'invalid' does not exist
// console.log(data.invalid)
})
React Integration
import { useEffect, useState } from 'react'
import { useAuth } from '@cloudillo/react'
import { RtdbClient } from '@cloudillo/rtdb'
import { getRtdbUrl } from '@cloudillo/core'
interface Todo {
title: string
completed: boolean
}
function TodoList({ dbId }: { dbId: string }) {
const [auth] = useAuth()
const [todos, setTodos] = useState<Todo[]>([])
const [rtdb, setRtdb] = useState<RtdbClient | null>(null)
// Initialize RTDB client
useEffect(() => {
if (!auth?.token || !auth?.idTag) return
const client = new RtdbClient({
dbId,
auth: { getToken: () => auth.token },
serverUrl: getRtdbUrl(auth.idTag, dbId, auth.token!)
})
setRtdb(client)
return () => {
client.disconnect()
}
}, [auth?.token, auth?.idTag, dbId])
// Subscribe to todos
useEffect(() => {
if (!rtdb) return
const todos = rtdb.collection<Todo>('todos')
const unsubscribe = todos.onSnapshot((snapshot) => {
setTodos(snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})))
})
return () => unsubscribe()
}, [rtdb])
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
Error Handling
The RTDB client provides typed errors:
import {
RtdbError,
ConnectionError,
AuthError,
PermissionError,
NotFoundError,
ValidationError,
TimeoutError
} from '@cloudillo/rtdb'
try {
await rtdb.connect()
} catch (error) {
if (error instanceof ConnectionError) {
console.log('Connection failed:', error.message)
} else if (error instanceof AuthError) {
console.log('Authentication failed:', error.message)
} else if (error instanceof PermissionError) {
console.log('Permission denied:', error.message)
} else if (error instanceof NotFoundError) {
console.log('Not found:', error.message)
} else if (error instanceof TimeoutError) {
console.log('Request timed out:', error.message)
}
}
Best Practices
1. Use Connection Management
// Initialize once, reuse
const rtdb = new RtdbClient({ ... })
await rtdb.connect()
// Use throughout your app
const todos = rtdb.collection('todos')
2. Clean Up Subscriptions
// Always unsubscribe to prevent memory leaks
useEffect(() => {
const unsubscribe = todos.onSnapshot(callback)
return () => unsubscribe()
}, [])
3. Use Batch for Multiple Operations
// Good: Atomic batch operation
const batch = rtdb.batch()
batch.create(todos, { title: 'Task 1' })
batch.create(todos, { title: 'Task 2' })
await batch.commit()
// Avoid: Multiple separate requests
// await create({ title: 'Task 1' })
// await create({ title: 'Task 2' })
4. Use Typed Collections
// Define your types
interface Todo {
title: string
completed: boolean
}
// Get type safety
const todos = rtdb.collection<Todo>('todos')
5. Handle Connection State
// Check connection before operations
if (!rtdb.isConnected()) {
await rtdb.connect()
}
// Handle reconnection in UI
const [connected, setConnected] = useState(false)
See Also
CRDT (Collaborative Editing)
Cloudillo uses Yjs for real-time collaborative editing with CRDT (Conflict-Free Replicated Data Types).
CRDT vs RTDB
CRDT is best for collaborative editing where multiple users edit simultaneously. For structured data with queries (todos, settings, lists), see RTDB. Compare all storage types in Data Storage & Access.
Installation
Quick Start
import * as cloudillo from '@cloudillo/core'
import * as Y from 'yjs'
// Initialize
await cloudillo.init('my-app')
// Create Yjs document
const yDoc = new Y.Doc()
// Open collaborative document
const { provider } = await cloudillo.openYDoc(yDoc, 'my-document-id')
// Use shared text
const yText = yDoc.getText('content')
yText.insert(0, 'Hello, collaborative world!')
// Listen for changes
yText.observe(() => {
console.log('Text updated:', yText.toString())
})
Shared Types
Yjs provides several shared data types:
YText - Shared Text
Best for plain text or rich text content.
const yText = yDoc.getText('content')
// Insert text
yText.insert(0, 'Hello ')
yText.insert(6, 'world!')
// Delete text
yText.delete(0, 5) // Delete 5 characters from position 0
// Format text (for rich text)
yText.format(0, 5, { bold: true })
// Get text content
console.log(yText.toString()) // "world!"
// Observe changes
yText.observe((event) => {
event.changes.delta.forEach(change => {
if (change.insert) {
console.log('Inserted:', change.insert)
}
if (change.delete) {
console.log('Deleted', change.delete, 'characters')
}
})
})
YMap - Shared Object
Best for key-value data like form fields or settings.
const yMap = yDoc.getMap('settings')
// Set values
yMap.set('theme', 'dark')
yMap.set('fontSize', 14)
yMap.set('notifications', true)
// Get values
console.log(yMap.get('theme')) // "dark"
// Delete keys
yMap.delete('fontSize')
// Check existence
console.log(yMap.has('theme')) // true
// Iterate
yMap.forEach((value, key) => {
console.log(`${key}: ${value}`)
})
// Observe changes
yMap.observe((event) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'add') {
console.log(`Added ${key}:`, yMap.get(key))
} else if (change.action === 'update') {
console.log(`Updated ${key}:`, yMap.get(key))
} else if (change.action === 'delete') {
console.log(`Deleted ${key}`)
}
})
})
YArray - Shared Array
Best for lists like todos, comments, or items.
const yArray = yDoc.getArray('todos')
// Push items
yArray.push([
{ title: 'Task 1', done: false },
{ title: 'Task 2', done: false }
])
// Insert at position
yArray.insert(0, [{ title: 'Urgent task', done: false }])
// Delete
yArray.delete(0, 1) // Delete 1 item at position 0
// Get items
console.log(yArray.get(0)) // First item
console.log(yArray.toArray()) // All items as array
// Iterate
yArray.forEach((item, index) => {
console.log(index, item)
})
// Observe changes
yArray.observe((event) => {
console.log('Array changed:', event.changes)
})
YXmlFragment - Shared XML/HTML
Best for rich text editors with complex formatting.
const yXml = yDoc.getXmlFragment('document')
// Create elements
const paragraph = new Y.XmlElement('p')
paragraph.setAttribute('class', 'text')
paragraph.insert(0, [new Y.XmlText('Hello world')])
yXml.insert(0, [paragraph])
Awareness
Awareness tracks user presence, cursors, and selections in real-time.
const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')
// Set local awareness state
provider.awareness.setLocalState({
user: {
name: cloudillo.name,
idTag: cloudillo.idTag,
color: '#ff6b6b'
},
cursor: {
line: 10,
column: 5
},
selection: {
from: 100,
to: 150
}
})
// Listen for awareness changes
provider.awareness.on('change', () => {
const states = provider.awareness.getStates()
states.forEach((state, clientId) => {
if (state.user) {
console.log(`User ${state.user.name} at cursor ${state.cursor}`)
}
})
})
// Get specific client state
const clientId = provider.awareness.clientID
const state = provider.awareness.getStates().get(clientId)
Editor Bindings
Yjs provides bindings for popular editors:
Quill (Rich Text)
import Quill from 'quill'
import { QuillBinding } from 'y-quill'
// Create Yjs document
const yDoc = new Y.Doc()
const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')
const yText = yDoc.getText('content')
// Create Quill editor
const editor = new Quill('#editor', {
theme: 'snow'
})
// Bind Yjs to Quill
const binding = new QuillBinding(yText, editor, provider.awareness)
// Quill now syncs with Yjs automatically
CodeMirror (Code Editor)
pnpm add codemirror y-codemirror
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'
const yText = yDoc.getText('content')
const editor = new EditorView({
extensions: [
basicSetup,
yCollab(yText, provider.awareness)
],
parent: document.querySelector('#editor')
})
Monaco (VS Code Editor)
pnpm add monaco-editor y-monaco
import * as monaco from 'monaco-editor'
import { MonacoBinding } from 'y-monaco'
const yText = yDoc.getText('content')
const editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript'
})
const binding = new MonacoBinding(
yText,
editor.getModel(),
new Set([editor]),
provider.awareness
)
ProseMirror (Rich Text)
pnpm add prosemirror-view prosemirror-state y-prosemirror
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'
const yXml = yDoc.getXmlFragment('prosemirror')
const state = EditorState.create({
schema,
plugins: [
ySyncPlugin(yXml),
yCursorPlugin(provider.awareness),
yUndoPlugin()
]
})
const view = new EditorView(document.querySelector('#editor'), {
state
})
Offline Support
Yjs documents work offline and sync when reconnected.
import { IndexeddbPersistence } from 'y-indexeddb'
const yDoc = new Y.Doc()
// Persist to IndexedDB
const indexeddbProvider = new IndexeddbPersistence('my-doc-id', yDoc)
indexeddbProvider.on('synced', () => {
console.log('Loaded from IndexedDB')
})
// Also connect to server
const { provider } = await cloudillo.openYDoc(yDoc, 'my-doc-id')
// Now works offline with local persistence
// Syncs to server when connection available
Transactions
Group multiple changes into a single transaction:
yDoc.transact(() => {
const yText = yDoc.getText('content')
yText.insert(0, 'Hello ')
yText.insert(6, 'world!')
yText.format(0, 11, { bold: true })
})
// All changes sync as one update
// Only one observer event fired
Undo/Redo
import { UndoManager } from 'yjs'
const yText = yDoc.getText('content')
const undoManager = new UndoManager(yText)
// Make changes
yText.insert(0, 'Hello')
// Undo
undoManager.undo()
// Redo
undoManager.redo()
// Track who made changes
undoManager.on('stack-item-added', (event) => {
console.log('Change by:', event.origin)
})
Document Lifecycle
// Create document
const yDoc = new Y.Doc()
// Open collaborative connection
const { provider } = await cloudillo.openYDoc(yDoc, 'doc_123')
// Use document...
// Close connection
provider.destroy()
// Destroy document
yDoc.destroy()
Best Practices
1. Use Subdocs for Large Documents
const yDoc = new Y.Doc()
const yMap = yDoc.getMap('pages')
// Create subdocument for each page
const page1 = new Y.Doc()
yMap.set('page1', page1)
const page1Text = page1.getText('content')
page1Text.insert(0, 'Page 1 content')
2. Batch Operations in Transactions
// ✅ Single transaction
yDoc.transact(() => {
for (let i = 0; i < 100; i++) {
yText.insert(i, 'x')
}
})
// ❌ Many transactions
for (let i = 0; i < 100; i++) {
yText.insert(i, 'x') // Sends 100 updates!
}
3. Clean Up Observers
// Add observer
const observer = (event) => {
console.log('Changed:', event)
}
yText.observe(observer)
// Remove observer when done
yText.unobserve(observer)
4. Handle Connection State
provider.on('status', ({ status }) => {
if (status === 'connected') {
setConnectionStatus('online')
} else {
setConnectionStatus('offline')
}
})
React Example
Complete collaborative editor in React:
import { useEffect, useState } from 'react'
import { useApi } from '@cloudillo/react'
import * as Y from 'yjs'
import * as cloudillo from '@cloudillo/core'
function CollaborativeEditor({ docId }) {
const [yDoc, setYDoc] = useState(null)
const [provider, setProvider] = useState(null)
const [connected, setConnected] = useState(false)
useEffect(() => {
const doc = new Y.Doc()
setYDoc(doc)
cloudillo.openYDoc(doc, docId).then(({ provider: p }) => {
setProvider(p)
p.on('status', ({ status }) => {
setConnected(status === 'connected')
})
})
return () => {
provider?.destroy()
doc.destroy()
}
}, [docId])
if (!yDoc) return <div>Loading...</div>
return (
<div>
<div className="status">
{connected ? '🟢 Connected' : '🔴 Disconnected'}
</div>
<Editor yDoc={yDoc} provider={provider} />
</div>
)
}
See Also
Error Handling
Cloudillo uses structured error codes and standardized error responses for consistent error handling across the platform.
All API errors return this structure:
{
"error": {
"code": "E-AUTH-UNAUTH",
"message": "Unauthorized access",
"details": {
"reason": "Token expired",
"expiredAt": 1735000000
}
},
"time": 1735000000,
"reqId": "req_abc123"
}
Fields:
error.code - Structured error code (see below)
error.message - Human-readable error message
error.details - Optional additional context
time - Unix timestamp (seconds)
reqId - Request ID for tracing
Error codes follow the pattern: E-MODULE-ERRTYPE
E- - Prefix (all error codes start with this)
MODULE - Module identifier (AUTH, CORE, SYS, etc.)
ERRTYPE - Error type (UNAUTH, NOTFOUND, etc.)
Error Codes
Authentication Errors (AUTH)
| Code |
HTTP Status |
Description |
E-AUTH-UNAUTH |
401 |
Unauthorized - Invalid or missing token |
E-AUTH-FORBID |
403 |
Forbidden - Insufficient permissions |
E-AUTH-EXPIRED |
401 |
Token expired |
E-AUTH-INVALID |
400 |
Invalid credentials |
E-AUTH-MISMATCH |
400 |
Password mismatch |
E-AUTH-EXISTS |
409 |
User already exists |
Core Errors (CORE)
| Code |
HTTP Status |
Description |
E-CORE-NOTFOUND |
404 |
Resource not found |
E-CORE-CONFLICT |
409 |
Resource conflict (duplicate) |
E-CORE-INVALID |
400 |
Invalid request data |
E-CORE-BADREQ |
400 |
Malformed request |
E-CORE-LIMIT |
429 |
Rate limit exceeded |
System Errors (SYS)
| Code |
HTTP Status |
Description |
E-SYS-UNAVAIL |
503 |
Service unavailable |
E-SYS-INTERNAL |
500 |
Internal server error |
E-SYS-TIMEOUT |
504 |
Request timeout |
E-SYS-STORAGE |
507 |
Storage full |
File Errors (FILE)
| Code |
HTTP Status |
Description |
E-FILE-NOTFOUND |
404 |
File not found |
E-FILE-TOOLARGE |
413 |
File too large |
E-FILE-BADTYPE |
415 |
Unsupported file type |
E-FILE-CORRUPT |
422 |
Corrupted file |
Action Errors (ACTION)
| Code |
HTTP Status |
Description |
E-ACTION-NOTFOUND |
404 |
Action not found |
E-ACTION-INVALID |
400 |
Invalid action data |
E-ACTION-DENIED |
403 |
Action not allowed |
E-ACTION-EXPIRED |
410 |
Action expired |
Federation Errors (FED)
| Code |
HTTP Status |
Description |
E-FED-SIGFAIL |
400 |
Action signature verification failed |
E-FED-KEYNOTFOUND |
400 |
Issuer public key not found |
E-FED-EXPIRED |
400 |
Action token expired |
E-FED-NOTRUST |
403 |
No trust relationship with issuer |
E-POW-REQUIRED |
428 |
Proof-of-work required (CONN actions) |
Validation Errors (VAL)
| Code |
HTTP Status |
Description |
E-VAL-INVALID |
400 |
Validation error |
E-AUTH-NOPERM |
403 |
Permission denied |
Network Errors (NET)
| Code |
HTTP Status |
Description |
E-NET-TIMEOUT |
408 |
Network timeout |
Database Errors (CORE)
| Code |
HTTP Status |
Description |
E-CORE-DBERR |
500 |
Database error |
E-CORE-PARSE |
500 |
Parse error |
Identity Provider Errors (IDP)
| Code |
HTTP Status |
Description |
E-IDP-NOTFOUND |
404 |
Identity not found |
E-IDP-EXISTS |
409 |
Identity already exists |
E-IDP-INVALID |
400 |
Invalid identity format |
RTDB/CRDT Errors
| Code |
HTTP Status |
Description |
E-RTDB-NOTFOUND |
404 |
RTDB document not found |
E-CRDT-NOTFOUND |
404 |
CRDT document not found |
E-CRDT-CONFLICT |
409 |
CRDT merge conflict |
Handling Errors with FetchError
The @cloudillo/core library provides FetchError for consistent error handling:
import { FetchError } from '@cloudillo/core'
try {
const api = cloudillo.createApiClient()
const profile = await api.profiles.getOwn()
} catch (error) {
if (error instanceof FetchError) {
console.error('Error code:', error.code)
console.error('Message:', error.message)
console.error('HTTP status:', error.status)
console.error('Details:', error.details)
// Handle specific errors
switch (error.code) {
case 'E-AUTH-UNAUTH':
// Redirect to login
window.location.href = '/login'
break
case 'E-AUTH-FORBID':
// Show permission error
alert('You do not have permission to access this resource')
break
case 'E-CORE-NOTFOUND':
// Show not found message
console.log('Resource not found')
break
case 'E-CORE-LIMIT':
// Rate limited
console.log('Too many requests, please slow down')
break
default:
// Generic error
console.error('An error occurred:', error.message)
}
} else {
// Non-API error (network, parsing, etc.)
console.error('Unexpected error:', error)
}
}
Error Handling Patterns
Pattern 1: Global Error Handler
function createApiWithErrorHandling() {
const api = cloudillo.createApiClient()
// Wrap API methods with error handling
return new Proxy(api, {
get(target, prop) {
const original = target[prop]
if (typeof original === 'function') {
return async (...args) => {
try {
return await original.apply(target, args)
} catch (error) {
handleApiError(error)
throw error
}
}
}
return original
}
})
}
function handleApiError(error) {
if (error instanceof FetchError) {
switch (error.code) {
case 'E-AUTH-UNAUTH':
case 'E-AUTH-EXPIRED':
// Redirect to login
window.location.href = '/login'
break
case 'E-CORE-LIMIT':
// Show rate limit toast
showToast('Too many requests. Please slow down.')
break
default:
// Log to error tracking service
logErrorToSentry(error)
}
}
}
Pattern 2: React Error Boundary
import { Component } from 'react'
import { FetchError } from '@cloudillo/core'
class ApiErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch(error, errorInfo) {
if (error instanceof FetchError) {
console.error('API Error:', error.code, error.message)
// Handle specific errors
if (error.code === 'E-AUTH-UNAUTH') {
window.location.href = '/login'
}
}
}
render() {
if (this.state.error) {
return (
<div className="error">
<h1>Something went wrong</h1>
<p>{this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
Pattern 3: Retry Logic
async function fetchWithRetry(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
if (error instanceof FetchError) {
// Retry on transient errors
if (error.code === 'E-SYS-UNAVAIL' ||
error.code === 'E-SYS-TIMEOUT') {
if (i < maxRetries - 1) {
// Exponential backoff
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)))
continue
}
}
// Don't retry on auth or client errors
if (error.code.startsWith('E-AUTH-') ||
error.code.startsWith('E-CORE-')) {
throw error
}
}
throw error
}
}
}
// Usage
const data = await fetchWithRetry(() => api.profiles.getOwn())
Pattern 4: User-Friendly Messages
function getUserFriendlyMessage(error: FetchError): string {
const messages: Record<string, string> = {
'E-AUTH-UNAUTH': 'Please log in to continue',
'E-AUTH-FORBID': 'You don\'t have permission to do that',
'E-AUTH-EXPIRED': 'Your session has expired. Please log in again',
'E-CORE-NOTFOUND': 'The item you\'re looking for doesn\'t exist',
'E-CORE-LIMIT': 'You\'re making too many requests. Please slow down',
'E-FILE-TOOLARGE': 'This file is too large. Maximum size is 100MB',
'E-FILE-BADTYPE': 'This file type is not supported',
'E-SYS-UNAVAIL': 'The service is temporarily unavailable. Please try again later',
}
return messages[error.code] || 'An unexpected error occurred. Please try again'
}
// Usage
try {
await api.files.uploadBlob('default', file.name, file)
} catch (error) {
if (error instanceof FetchError) {
alert(getUserFriendlyMessage(error))
}
}
Pattern 5: Typed Error Handling
type ApiError = {
code: string
message: string
status: number
}
function isAuthError(error: ApiError): boolean {
return error.code.startsWith('E-AUTH-')
}
function isClientError(error: ApiError): boolean {
return error.status >= 400 && error.status < 500
}
function isServerError(error: ApiError): boolean {
return error.status >= 500
}
// Usage
try {
const data = await api.actions.create(newAction)
} catch (error) {
if (error instanceof FetchError) {
if (isAuthError(error)) {
handleAuthError(error)
} else if (isServerError(error)) {
showRetryPrompt()
}
}
}
Validation Errors
Client-side validation can prevent many errors:
import type { NewAction } from '@cloudillo/types'
function validateAction(action: NewAction): string[] {
const errors: string[] = []
if (!action.type) {
errors.push('Action type is required')
}
if (action.type === 'POST' && !action.content) {
errors.push('Post content is required')
}
if (action.attachments && action.attachments.length > 10) {
errors.push('Maximum 10 attachments allowed')
}
return errors
}
// Usage
const newAction = {
type: 'POST',
content: { text: 'Hello!' }
}
const validationErrors = validateAction(newAction)
if (validationErrors.length > 0) {
alert('Validation errors:\n' + validationErrors.join('\n'))
return
}
// Proceed with API call
await api.actions.create(newAction)
Logging and Monitoring
function logApiError(error: FetchError, context?: any) {
const logData = {
code: error.code,
message: error.message,
status: error.status,
url: error.response?.url,
context,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
user: cloudillo.idTag
}
// Send to logging service
fetch('/api/logs/error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logData)
})
// Also log to console in development
if (process.env.NODE_ENV === 'development') {
console.error('API Error:', logData)
}
}
Best Practices
1. Always Handle Errors
// ✅ Good - handle errors
try {
await api.actions.create(newAction)
} catch (error) {
handleError(error)
}
// ❌ Bad - unhandled errors crash the app
await api.actions.create(newAction)
2. Provide User Feedback
// ✅ Good - user knows what happened
try {
await api.files.uploadBlob('default', file.name, file)
showToast('File uploaded successfully!')
} catch (error) {
showToast('Upload failed. Please try again.')
}
// ❌ Bad - silent failure
try {
await api.files.uploadBlob('default', file.name, file)
} catch (error) {
console.error(error)
}
3. Differentiate Error Types
// ✅ Good - handle different errors appropriately
if (error.code === 'E-AUTH-UNAUTH') {
redirectToLogin()
} else if (error.code === 'E-CORE-NOTFOUND') {
show404Page()
} else {
showGenericError()
}
// ❌ Bad - same handling for all errors
alert('Error!')
4. Log Errors for Debugging
// ✅ Good - logs help debug issues
catch (error) {
console.error('Failed to create post:', {
error,
action: newAction,
user: cloudillo.idTag
})
showError('Failed to create post')
}
// ❌ Bad - no debugging info
catch (error) {
showError('Failed')
}
See Also
Microfrontend Integration
Cloudillo uses a microfrontend architecture where apps run as sandboxed iframes and communicate with the shell via a typed postMessage protocol.
Architecture overview
Apps are loaded into the Cloudillo shell as iframes with opaque origins (no allow-same-origin), ensuring strong isolation between apps and the shell. All communication flows through a message bus layer provided by @cloudillo/core.
- Isolation – apps are sandboxed, preventing access to the shell’s DOM, cookies, or service worker keys
- Technology agnostic – use any framework (React, Vue, vanilla JS)
- Independent deployment – update apps without redeploying the shell
- Shared authentication – tokens are managed by the shell and pushed to apps via the message bus
Getting started
Initialization with @cloudillo/core
The getAppBus() singleton provides the main API for apps to communicate with the shell:
import { getAppBus } from '@cloudillo/core'
async function main() {
const bus = getAppBus()
const state = await bus.init('my-app')
// state contains: idTag, tnId, roles, accessToken, access, darkMode, theme, ...
console.log('User:', state.idTag)
console.log('Access:', state.access) // 'read' | 'write'
// Token is also available as bus.accessToken
// bus.init() automatically calls notifyReady('auth')
}
main().catch(console.error)
AppState fields
The init() call returns an AppState object:
| Field |
Type |
Description |
idTag |
string? |
User’s identity tag |
tnId |
number? |
Tenant ID |
roles |
string[]? |
User roles |
accessToken |
string? |
JWT access token for API calls |
access |
'read' | 'write' |
Access level for the current resource |
darkMode |
boolean |
Dark mode preference |
theme |
string |
UI theme variant (e.g. 'glass') |
tokenLifetime |
number? |
Token lifetime in seconds |
displayName |
string? |
Display name (for guest users via share links) |
navState |
string? |
Navigation state passed from the shell |
Lifecycle notifications
Apps signal their readiness to the shell in stages using bus.notifyReady(stage):
| Stage |
When to call |
Notes |
'auth' |
After authentication completes |
Called automatically by bus.init() |
'synced' |
After CRDT/data sync completes |
Call manually when your data is loaded |
'ready' |
When the app is fully interactive |
Call when UI is ready for user interaction |
The shell shows a loading indicator until the app signals 'ready'. You can also report errors with bus.notifyError(code, message).
React integration
useCloudillo hook
The primary hook for React apps. It calls bus.init() internally and provides the app state:
import { useCloudillo, useAuth, useApi } from '@cloudillo/react'
function MyApp() {
const { token, ownerTag, fileId, idTag, roles, access, displayName } = useCloudillo('my-app')
const [auth] = useAuth() // [AuthState | null, setter] tuple
const { api } = useApi() // { api: ApiClient | null, authenticated, setIdTag }
if (!auth) return <div>Loading...</div>
return <div>Hello, {auth.idTag}</div>
}
Info
useCloudillo extracts ownerTag and fileId from the URL hash (#ownerTag:fileId). The hash is how the shell passes resource context to apps.
useCloudilloEditor hook
For collaborative document apps using CRDT:
import { useCloudilloEditor } from '@cloudillo/react'
function Editor() {
const { yDoc, provider, synced, error } = useCloudilloEditor('quillo')
if (error) return <div>Error: {error.code}</div>
if (!synced) return <div>Syncing...</div>
// yDoc is a Y.Doc connected to the collaborative backend
return <MyEditor yDoc={yDoc} />
}
This hook handles the full lifecycle: initialization, WebSocket connection, CRDT persistence, and cleanup on unmount. It automatically calls notifyReady('synced') when the document is synchronized.
Communication protocol
All messages follow the envelope format:
{ cloudillo: true, v: 1, type: 'category:action.verb', ... }
Message categories
| Category |
Direction |
Purpose |
auth:init.req |
app → shell |
Request initialization |
auth:init.res |
shell → app |
Respond with state and token |
auth:init.push |
shell → app |
Proactively push init (theme/state changes) |
auth:token.push |
shell → app |
Push refreshed token |
auth:token.refresh.req/res |
both |
Manual token refresh |
app:ready.notify |
app → shell |
Signal readiness stage |
app:error.notify |
app → shell |
Report error to shell |
storage:op.req/res |
both |
Key-value storage operations |
settings:get.req/res |
both |
App settings access |
media:pick.req/result |
both |
Media picker dialog |
doc:pick.req/result |
both |
Document picker dialog |
crdt:* |
both |
CRDT cache operations |
sensor:compass.* |
both |
Device sensor subscriptions |
Note
Apps don’t need to handle the protocol directly. The AppMessageBus class (via getAppBus()) provides typed methods for all operations. The protocol details are mainly useful for debugging or building non-JS integrations.
Storage and settings
Storage API
Apps have access to namespaced key-value storage via the message bus:
const bus = getAppBus()
// Store and retrieve data
await bus.storage.set('my-app', 'preferences', { theme: 'dark' })
const prefs = await bus.storage.get<{ theme: string }>('my-app', 'preferences')
// List keys and check quota
const keys = await bus.storage.list('my-app', 'cache:')
const { limit, used } = await bus.storage.quota('my-app')
| Method |
Signature |
Description |
get |
get<T>(ns, key): Promise<T?> |
Get a value by key |
set |
set(ns, key, value): Promise<void> |
Set a value |
delete |
delete(ns, key): Promise<void> |
Delete a key |
list |
list(ns, prefix?): Promise<string[]> |
List keys with optional prefix |
clear |
clear(ns): Promise<void> |
Clear all data in namespace |
quota |
quota(ns): Promise<{limit, used}> |
Get storage quota info |
Settings API
Apps can read and write server-side settings scoped to app.<appName>.*:
| Method |
Signature |
Description |
get |
get<T>(key): Promise<T?> |
Get a setting value |
set |
set(key, value): Promise<void> |
Set a setting value |
list |
list(prefix?): Promise<Array<{key, value}>> |
List settings |
Security
Warning
The shell loads app iframes with sandbox="allow-scripts allow-forms allow-downloads". The allow-same-origin attribute is deliberately excluded to create opaque origins, which prevents apps from accessing the shell’s service worker registration keys or cookies. This is a critical security boundary.
Token handling: Access tokens are held in memory only (inside the AppMessageBus instance). Never store tokens in localStorage or sessionStorage – even with opaque origins, this would create unnecessary persistence of credentials.
Message validation: The SDK validates all incoming messages automatically (protocol version, envelope structure, message type). Apps using getAppBus() do not need to implement manual postMessage validation.
Token refresh: The shell proactively pushes refreshed tokens via auth:token.push messages. Apps can also request a refresh manually with bus.refreshToken().
Debugging
Enable debug logging by passing a config to getAppBus():
const bus = getAppBus({ debug: true })
await bus.init('my-app')
// All message bus traffic will be logged to the console
To inspect an app’s iframe context in DevTools, use the console’s context selector dropdown to switch to the iframe’s execution context.
Example apps
Cloudillo includes several built-in apps that use these patterns:
| App |
Tech |
Features |
| Quillo |
Quill + Yjs |
Collaborative rich text editor |
| Prezillo |
Custom + Yjs |
Presentation slides and animations |
| Calcillo |
Fortune Sheet + Yjs |
Excel-like spreadsheets |
| Formillo |
React + RTDB |
Form builder and response collection |
| Taskillo |
React + RTDB |
Task management |
| Notillo |
React + Yjs |
Note-taking |
| Scanillo |
React |
Document scanning |
| Mapillo |
React |
Map visualization |
| Ideallo |
React + Yjs |
Collaborative ideation board |
See also