ratatoskr-client
v0.2.0
Published
Browser client library for Ratatoskr - automerge-repo sync server with authentication
Downloads
182
Maintainers
Readme
ratatoskr-client
Browser client library for Ratatoskr - an automerge-repo sync server with authentication and per-document permissions.
Features
- 🔐 Popup-based OIDC authentication - Seamless login flow
- 📡 WebSocket sync - Real-time document synchronization via automerge-repo
- 📄 Document management - Create, list, delete documents
- 🔑 Access control - Manage document ACLs and permissions
- 🎫 API tokens - Generate tokens for CLI/programmatic access
- 🌐 Works everywhere - Browser, bundlers, or direct ESM import
- 📴 Offline-first - Create and edit documents offline, sync when back online
- 💾 Persistent storage - Documents saved to IndexedDB survive browser sessions
Quick Start
Installation (npm/bun/yarn)
npm install ratatoskr-client @automerge/automerge-repoDirect ESM Import (No Build Step)
<script type="module">
import { RatatoskrClient } from 'https://esm.sh/ratatoskr-client';
import { Repo } from 'https://esm.sh/@automerge/automerge-repo';
const client = new RatatoskrClient({
serverUrl: window.location.origin
});
// Login via popup
const user = await client.login();
console.log('Logged in as:', user.name);
// Get automerge repo for real-time sync
const repo = client.getRepo();
</script>Complete Example
<!DOCTYPE html>
<html>
<head>
<title>Ratatoskr Example</title>
</head>
<body>
<button id="login">Login</button>
<button id="create">Create Document</button>
<div id="output"></div>
<script type="module">
import { RatatoskrClient } from 'https://esm.sh/ratatoskr-client';
const client = new RatatoskrClient({
serverUrl: window.location.origin
});
// Login button
document.getElementById('login').onclick = async () => {
try {
const user = await client.login();
document.getElementById('output').textContent = `Hello, ${user.name}!`;
} catch (err) {
alert('Login failed: ' + err.message);
}
};
// Create document button
document.getElementById('create').onclick = async () => {
const doc = await client.createDocument({
id: `doc:${crypto.randomUUID()}`,
type: 'notes'
});
console.log('Created:', doc);
};
</script>
</body>
</html>API Reference
RatatoskrClient
The main client class for interacting with a Ratatoskr server.
Constructor
new RatatoskrClient(options: RatatoskrClientOptions)| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| serverUrl | string | ✅ | - | Base URL of the Ratatoskr server |
| tokenStorageKey | string | ❌ | "ratatoskr:token" | localStorage key for token persistence |
| enableOfflineSupport | boolean | ❌ | true | Enable offline-first document creation with IndexedDB storage |
Authentication Methods
login(): Promise<User>
Opens a popup for OIDC authentication. Returns user info on success. Requires network connectivity.
const user = await client.login();
// { id: "alice", name: "Alice Smith", email: "[email protected]" }logout(): void
Clears stored credentials and disconnects.
client.logout();isAuthenticated(): boolean
Check if user has a stored token (doesn't validate the token).
if (client.isAuthenticated()) {
// Token exists (may still be expired)
}hasStoredCredentials(): boolean
Check if both token and user info are cached. Useful for showing "Welcome back" UI even when offline.
if (client.hasStoredCredentials()) {
console.log(`Welcome back, ${client.getUser()?.name}!`);
}getUser(): User | null
Get the current user object. When offline with stored credentials, returns the cached user.
fetchUserInfo(): Promise<User>
Fetch current user info from the server. Updates the cached user info. Useful to validate stored tokens.
try {
const user = await client.fetchUserInfo();
console.log('Token valid for:', user.name);
} catch {
console.log('Token expired, need to login again');
}validateToken(): Promise<boolean>
Validate the stored token by fetching user info. Returns true if valid, false if expired/invalid.
if (client.isAuthenticated()) {
const isValid = await client.validateToken();
if (!isValid) {
// Token expired, prompt re-login
}
}Automerge Integration
getRepo(): Repo
Get or create the automerge-repo instance connected to the server.
const repo = client.getRepo();
// Find an existing document
const handle = repo.find('doc:abc123');
await handle.whenReady();
const doc = handle.docSync();
// Create a new document
const newHandle = repo.create();
newHandle.change(doc => {
doc.title = 'My Document';
doc.items = [];
});disconnect(): void
Disconnect from the server and clean up the repo.
client.disconnect();Document Management
createDocument(request): Promise<DocumentMetadata>
Create a new document on the server.
const doc = await client.createDocument({
id: 'doc:my-unique-id', // Required: doc:, app:, or eph: prefix
type: 'notes', // Optional: document type
acl: [ // Optional: initial ACL
{ principal: 'bob', permission: 'write' },
{ principal: 'public', permission: 'read' }
],
expiresAt: '2025-12-31' // Optional: auto-delete date
});Document ID Prefixes:
| Prefix | Example | Description |
|--------|---------|-------------|
| doc: | doc:abc123 | Regular document with full ACL support |
| app: | app:myapp | Per-user-per-app private document |
| eph: | eph:session | Ephemeral document (no persistence) |
listDocuments(): Promise<ListDocumentsResponse>
List documents you own or have access to.
const { owned, accessible } = await client.listDocuments();
console.log('My documents:', owned);
console.log('Shared with me:', accessible);getDocument(id): Promise<DocumentMetadata>
Get metadata for a specific document.
const doc = await client.getDocument('doc:abc123');
console.log('Owner:', doc.owner);
console.log('Size:', doc.size);deleteDocument(id): Promise<void>
Delete a document (owner only).
await client.deleteDocument('doc:abc123');Access Control
getDocumentACL(id): Promise<ACLEntry[]>
Get the access control list for a document.
const acl = await client.getDocumentACL('doc:abc123');
// [{ principal: 'bob', permission: 'write' }]setDocumentACL(id, acl): Promise<void>
Update a document's ACL (owner only).
await client.setDocumentACL('doc:abc123', [
{ principal: 'bob', permission: 'write' },
{ principal: 'charlie', permission: 'read' },
{ principal: 'public', permission: 'read' } // Anyone can read
]);ACL Entries:
| Principal | Description |
|-----------|-------------|
| User ID | Grant access to specific user |
| Document ID | Inherit ACL from another document |
| "public" | Grant access to everyone (including anonymous) |
Permissions:
| Permission | Allows |
|------------|--------|
| "read" | Read document content |
| "write" | Read and write document content |
API Tokens
createApiToken(name, scopes?, expiresAt?): Promise<{token, id}>
Create an API token for CLI or programmatic access.
const { token, id } = await client.createApiToken(
'my-cli-tool',
['read', 'write'], // Optional scopes
'2026-01-01T00:00:00Z' // Optional expiration
);
console.log('Save this token:', token); // Only shown once!listApiTokens(): Promise<ApiToken[]>
List all your API tokens (tokens values are not returned).
const tokens = await client.listApiTokens();
for (const t of tokens) {
console.log(`${t.name} (${t.id}) - last used: ${t.lastUsedAt}`);
}deleteApiToken(id): Promise<void>
Revoke an API token.
await client.deleteApiToken('token-id-here');Offline-First Document Creation
Documents can be created and edited offline. They'll be registered on the server when connectivity and authentication are restored.
createDocumentOffline<T>(initialValue, options?): Promise<string>
Create a document that works offline. Returns the document ID immediately.
// Create document offline - works even without network
const docId = await client.createDocumentOffline(
{ title: 'My Notes', items: [] },
{ type: 'notes' }
);
// Document is usable immediately via automerge
const handle = client.getRepo().find(docId);
handle.change(doc => {
doc.items.push('First item');
});
// When online + authenticated, document auto-registers on serverNote: Documents created offline are private (no ACLs) until you're online. You can set ACLs after the document syncs using setDocumentACL().
getDocumentSyncStatus(documentId): Promise<DocumentStatusEntry | undefined>
Get the sync status of a document.
const status = await client.getDocumentSyncStatus(docId);
if (status) {
console.log('Status:', status.status); // 'local' | 'syncing' | 'synced'
console.log('Registered on server:', status.serverRegistered);
}getConnectivityState(): ConnectivityState
Get the current connectivity state.
const state = client.getConnectivityState();
// 'online' | 'offline' | 'connecting'processPendingOperations(): Promise<{processed, failed}>
Force processing of pending sync operations. Useful after login.
await client.login();
const result = await client.processPendingOperations();
console.log(`Synced ${result.processed} documents`);getPendingOperationsCount(): Promise<number>
Get the number of pending sync operations.
getUnsyncedDocuments(): Promise<DocumentStatusEntry[]>
Get all documents that haven't been synced to the server yet.
onSyncEvent(listener): () => void
Subscribe to sync events. Returns an unsubscribe function.
const unsubscribe = client.onSyncEvent((event) => {
switch (event.type) {
case 'connectivity:changed':
console.log('Connectivity:', event.connectivity);
break;
case 'document:status-changed':
console.log(`Document ${event.documentId}: ${event.status}`);
break;
case 'sync:completed':
console.log(`Synced ${event.processed} documents`);
break;
case 'auth:required':
console.log('Please log in to sync');
break;
case 'auth:token-expired':
console.log('Token expired, please log in again');
break;
}
});
// Later: unsubscribe()Event Types:
| Event | Description |
|-------|-------------|
| connectivity:changed | Network/server connection state changed |
| document:status-changed | A document's sync status changed |
| sync:started | Sync processing started |
| sync:completed | Sync processing completed |
| sync:error | Sync error occurred |
| auth:required | Authentication needed to continue syncing |
| auth:token-expired | Token is invalid/expired |
isOfflineEnabled(): boolean
Check if offline support is enabled.
destroy(): void
Cleanup all resources including IndexedDB connections.
Types
interface User {
id: string;
email?: string;
name?: string;
}
interface DocumentMetadata {
id: string;
owner: string;
type: string | null;
size: number;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
interface ACLEntry {
principal: string; // User ID, document ID, or "public"
permission: 'read' | 'write';
}
interface ApiToken {
id: string;
name: string;
scopes: string[];
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
}
// Offline support types
type ConnectivityState = 'online' | 'offline' | 'connecting';
type DocumentSyncStatus = 'local' | 'syncing' | 'synced';
interface DocumentStatusEntry {
documentId: string;
status: DocumentSyncStatus;
serverRegistered: boolean;
createdAt: string;
lastSyncAttempt?: string;
error?: string;
}
type SyncEventType =
| 'sync:started'
| 'sync:completed'
| 'sync:error'
| 'document:status-changed'
| 'connectivity:changed'
| 'auth:required'
| 'auth:token-expired';
interface SyncEvent {
type: SyncEventType;
documentId?: string;
status?: DocumentSyncStatus;
connectivity?: ConnectivityState;
error?: string;
processed?: number;
failed?: number;
}RatatoskrNetworkAdapter
Low-level network adapter for custom automerge-repo setups.
import { Repo } from '@automerge/automerge-repo';
import { RatatoskrNetworkAdapter } from 'ratatoskr-client';
const adapter = new RatatoskrNetworkAdapter({
serverUrl: 'https://your-server.com',
token: 'your-auth-token' // Optional
});
const repo = new Repo({
network: [adapter],
peerId: 'my-peer-id'
});
// Update token after login
adapter.setToken(newToken);Usage Patterns
Single-File HTML Tool
<!DOCTYPE html>
<html>
<head>
<title>My Tool</title>
<script type="importmap">
{
"imports": {
"ratatoskr-client": "https://esm.sh/ratatoskr-client?external=@automerge/automerge-repo",
"@automerge/automerge-repo": "https://esm.sh/@automerge/automerge-repo"
}
}
</script>
</head>
<body>
<script type="module">
import { RatatoskrClient } from 'ratatoskr-client';
const client = new RatatoskrClient({
serverUrl: window.location.origin
});
// Check for existing session
if (client.isAuthenticated()) {
try {
await client.fetchUserInfo();
startApp();
} catch {
client.logout();
showLogin();
}
} else {
showLogin();
}
function showLogin() {
// Show login UI
}
async function startApp() {
const repo = client.getRepo();
// Use automerge-repo for real-time collaboration
}
</script>
</body>
</html>React/Vue/Svelte App
// src/lib/ratatoskr.ts
import { RatatoskrClient } from 'ratatoskr-client';
export const client = new RatatoskrClient({
serverUrl: import.meta.env.VITE_RATATOSKR_URL
});
// React hook example
export function useRatatoskr() {
const [user, setUser] = useState(client.getUser());
const login = async () => {
const u = await client.login();
setUser(u);
};
const logout = () => {
client.logout();
setUser(null);
};
return { client, user, login, logout };
}CLI Tool with API Token
// Use the token directly with fetch or the network adapter
const response = await fetch(`${process.env.RATATOSKR_URL}/api/v1/documents`, {
headers: {
'Authorization': `Bearer ${process.env.RATATOSKR_TOKEN}`
}
});Error Handling
All async methods throw errors on failure:
try {
await client.login();
} catch (err) {
if (err.message.includes('popup')) {
// Popup was blocked or closed
} else if (err.message.includes('timeout')) {
// Authentication took too long
}
}
try {
await client.createDocument({ id: 'doc:test' });
} catch (err) {
// err.message contains server error details
}Browser Compatibility
- Modern browsers with ES2022 support
- Requires
crypto.randomUUID()(Chrome 92+, Firefox 95+, Safari 15.4+) - WebSocket support required
License
MIT
