Documentation
Everything you need to build a complete backend with Cubby. Auth, database, storage, realtime, and more — all in Dart.
Installation
Cubby can be used as a standalone CLI or as a dependency in your Dart project. Both approaches give you a complete backend with auth, database, storage, and realtime out of the box.
Option 1: Global CLI
Install Cubby globally and start a server with zero configuration:
dart pub global activate cubby cubby start
This starts a fully functional backend on port 8080 with a built-in admin dashboard at /_/ . Use the CLI to manage users, collections, and documents directly from your terminal.
Option 2: Add to your project
For custom routes, hooks, and full programmatic control, add Cubby as a dependency:
dependencies: cubby: ^0.1.0
Then create a server entry point:
import 'package:cubby/cubby.dart'; void main(List<String> args) async { final server = Cubby( port: 8080, jwtSecret: 'your-secret-key', ); // Create collections, add hooks, register routes... await server.run(args); }
Run your server:
dart run bin/server.dart start
Configuration
Configure Cubby through the constructor or environment variables. Constructor values take precedence over environment variables.
Constructor Parameters
Cubby( port: 8080, // Server port jwtSecret: 'your-secret-key', // JWT signing key appUrl: 'http://localhost:8080', // Public URL localStorageEnabled: true, // Enable local file storage localStoragePath: 'storage', // Storage directory logLevel: LogLevel.info, // debug | info | warn | error maxFileSize: 50 * 1024 * 1024, // 50MB databasePath: 'database.sqlite', // SQLite file path )
Environment Variables
All settings can also be configured via environment variables:
| Variable | Default | Description |
|---|---|---|
| CUBBY_PORT | 8080 | Server port |
| CUBBY_SECRET_KEY | super_secret_jwt_key | JWT signing secret |
| CUBBY_APP_URL | http://localhost:8080 | Public application URL |
| CUBBY_LOCAL_STORAGE_ENABLED | true | Enable local file storage |
| CUBBY_LOCAL_STORAGE_PATH | storage | Directory for local file storage |
| CUBBY_LOG_LEVEL | info | Log level (debug, info, warn, error) |
| CUBBY_MAX_FILE_SIZE | 52428800 | Max upload size in bytes (50MB) |
| CUBBY_DATABASE_PATH | ./database.sqlite | SQLite database file path |
| CUBBY_RATE_LIMIT_MAX | 10 | Max requests per rate limit window |
| CUBBY_RATE_LIMIT_WINDOW_SECONDS | 60 | Rate limit window in seconds |
| CUBBY_LOG_RETENTION_DAYS | 30 | Days to retain logs (0 = forever) |
Public API
The Cubby instance exposes services for programmatic access to every subsystem:
final server = Cubby(); server.users // UsersService server.auth // AuthService server.collections // CollectionsService server.documents // DocumentsService server.storage // StorageService server.settings // SettingsService server.logs // LogsService server.database // AppDatabase (Drift ORM) server.realtime // RealtimeEventBus server.hooks // HookRegistry
Collections
Collections are the core data model in Cubby. There are two types: Base collections (read-write tables) and View collections (read-only SQL views).
Base Collections
Base collections are writable tables with automatic id, created_at, and updated_at fields. Define your schema using typed attributes:
await server.collections.createBase( name: 'posts', attributes: [ TextAttribute(name: 'title', nullable: false), TextAttribute(name: 'body'), BoolAttribute(name: 'published', defaultValue: false), IntAttribute(name: 'views', defaultValue: 0), TextAttribute(name: 'authorId', foreignKey: ForeignKey( table: 'users', column: 'id', onDelete: ForeignKeyAction.cascade, ), ), ], indexes: [ Index(name: 'idx_posts_author', columns: ['authorId']), ], listRule: 'auth.uid != null', createRule: 'auth.uid != null', viewRule: 'auth.uid != null', updateRule: 'resource.data[authorId] == auth.uid', deleteRule: 'resource.data[authorId] == auth.uid', );
Attribute Types
Six built-in attribute types cover all common data needs:
| Type | SQL Type | Dart Type |
|---|---|---|
| TextAttribute | TEXT | String |
| IntAttribute | INTEGER | int |
| DoubleAttribute | REAL | double |
| BoolAttribute | INTEGER (0/1) | bool |
| DateAttribute | INTEGER (unix) | DateTime |
| JsonAttribute | TEXT (JSON) | Map / List |
All attributes support these options:
TextAttribute( name: 'email', nullable: false, // Required field unique: true, // Unique constraint defaultValue: '(random_uuid_v7())', checkConstraint: 'length(email) > 3', foreignKey: ForeignKey(...), )
View Collections
Views are read-only collections backed by a SQL SELECT query. They must include an id column and are useful for aggregations, joins, and computed data:
await server.collections.createView( name: 'post_stats', viewQuery: ''' SELECT p.id, p.title, p.authorId, u.name as authorName, p.views, p.created_at FROM posts p JOIN users u ON p.authorId = u.id WHERE p.published = 1 ''', listRule: 'auth.uid != null', );
Collections API
Manage collections programmatically through the service:
// List all collections final all = await server.collections.list(); // Get a specific collection final posts = await server.collections.getByName('posts'); // Check existence final exists = await server.collections.exists('posts'); // Update a collection await server.collections.updateBase( name: 'posts', listRule: 'true', // Make public ); // Delete a collection await server.collections.delete('posts');
Permissions
Cubby uses expression-based permission rules powered by the expressions package. Rules are strings that evaluate to a boolean, providing fine-grained access control for every operation.
Rule Types
Each collection has five permission rules. A null rule denies all access for that operation. The string "true" allows unrestricted access.
| Rule | Controls | Applies To |
|---|---|---|
| listRule | Listing/querying documents | Base & View |
| viewRule | Reading a single document | Base & View |
| createRule | Creating new documents | Base only |
| updateRule | Updating existing documents | Base only |
| deleteRule | Deleting documents | Base only |
Context Objects
Rules have access to these objects when evaluating:
// auth — current user info auth.uid // User ID (null if guest) auth.isSuperUser // Admin flag auth.token // Bearer token string // resource — the document being accessed resource.id // Document ID resource.collection // Collection name resource.data['field'] // Access any field // request — HTTP request context request.method // HTTP method request.path // Request path request.timestamp // Current time
Example Rules
// Only authenticated users 'auth.uid != null' // Only the document author 'resource.data[authorId] == auth.uid' // Admin only 'auth.isSuperUser == true' // Author OR admin 'resource.data[authorId] == auth.uid || auth.isSuperUser' // Check a related document 'get(teams, resource.data[teamId]).data[leadId] == auth.uid' // Public access 'true' // Deny all access (or use null) 'false'
Available Methods in Rules
Rules support full Dart-like expression syntax. Methods available on values depend on their type:
| Type | Methods & Properties |
|---|---|
| String | contains(), startsWith(), endsWith(), toLowerCase(), toUpperCase(), trim(), length, isEmpty, isNotEmpty, split(), substring(), replaceAll() |
| List | contains(), length, isEmpty, isNotEmpty, first, last, where(), join(), elementAt() |
| Map | keys, values, containsKey(), containsValue() |
| DateTime | year, month, day, hour, minute, second, weekday, toIso8601String() |
| num | abs(), ceil(), floor(), round(), clamp(), toInt(), toDouble() |
Helper Functions
Two helper functions let you query related data within rules:
// Fetch a document from another collection get('collection', 'documentId') // Returns Document // Check if a document exists exists('collection', 'documentId') // Returns bool
File Storage
Cubby provides built-in file storage with two backends: local filesystem and S3-compatible services (AWS S3, MinIO, etc.). Files are organized into buckets, each with their own permission rules.
Buckets
Create and manage storage buckets with permission rules:
await server.storage.createBucket( name: 'avatars', createRule: 'auth.uid != null', // Auth users can upload viewRule: 'true', // Anyone can view deleteRule: 'resource.file.metadata[uploadedBy] == auth.uid', );
Upload Files
Upload files via the REST API using multipart form data:
POST /v1/files/avatars/upload Content-Type: multipart/form-data Form fields: path: "users/profile.jpg" // Destination path file: (binary) // File content uploadedBy: "user_123" // Custom metadata
Download Files
// By path (with download token) GET /v1/files/avatars/download/users/profile.jpg?token=abc123 // By file ID GET /v1/files/avatars/download/file_id_here // Get a temporary download URL GET /v1/files/avatars/get-download-url/file_id_here
Programmatic File Operations
// Upload a file final file = await server.storage.uploadFile( bucket: 'avatars', path: 'users/profile.jpg', data: fileStream, mimeType: 'image/jpeg', metadata: {'uploadedBy': userId}, ); // List files in a bucket final result = await server.storage.listFiles( bucket: 'avatars', path: 'users/', limit: 20, orderBy: 'created_at DESC', ); // Get file by ID or path final file = await server.storage.getFileById(id); final file = await server.storage.getFileByPath( bucket: 'avatars', path: 'users/profile.jpg', );
S3 Configuration
Connect to any S3-compatible storage service via settings:
await server.settings.update( s3: S3Settings( endpoint: 'https://s3.amazonaws.com', bucket: 'my-app-files', region: 'us-east-1', accessKey: 'AKIA...', secretKey: 'wJal...', enabled: true, ), );
Hooks
Hooks let you intercept and modify operations at specific lifecycle points. Use "before" hooks to validate or transform data, and "after" hooks to trigger side effects.
Document Hooks
// Transform data before creating a document server.hooks.onBeforeDocumentCreate((event) async { event.data['slug'] = slugify(event.data['title']); event.data['wordCount'] = countWords(event.data['body']); }); // Send notification after a document is created server.hooks.onAfterDocumentCreate((event) async { await sendNotification(event.result); }); // Validate updates server.hooks.onBeforeDocumentUpdate((event) async { if (event.data.containsKey('status')) { validateStatusTransition(event.data['status']); } }); // Clean up after deletion server.hooks.onAfterDocumentDelete((event) async { await cleanupRelatedData(event.documentId); });
User Hooks
// Set defaults for new users server.hooks.onBeforeUserCreate((event) async { event.email = event.email.toLowerCase(); }); // Post-registration setup server.hooks.onAfterUserCreate((event) async { await createDefaultWorkspace(event.result.id); });
Auth Hooks
server.hooks.onBeforeAuthSignIn((event) async { // Rate limiting, IP checks, etc. }); server.hooks.onAfterAuthSignIn((event) async { await logSignIn(event.result.user); });
File Hooks
server.hooks.onBeforeFileUpload((event) async { // Validate file type if (!allowedTypes.contains(event.mimeType)) { throw CubbyException('File type not allowed'); } }); server.hooks.onAfterFileUpload((event) async { await generateThumbnail(event.result); });
Collection Hooks
server.hooks.onBeforeCollectionCreate((event) async { // Enforce naming conventions event.name = event.name.toLowerCase(); }); server.hooks.onAfterCollectionDelete((event) async { await cleanupBuckets(event.name); });
Note: "Before" hooks receive mutable event data — you can modify fields directly. "After" hooks receive immutable results. If a before hook throws an exception, the operation is cancelled.
Custom Routes
Add your own HTTP endpoints alongside the built-in API. Custom routes receive a standard Shelf Request and have access to all Cubby services through request extensions.
Adding Routes
final server = Cubby(); // Simple GET endpoint server.addRoute(HttpMethod.get, '/api/status', (request) { return Response.ok(jsonEncode({'status': 'healthy'})); }); // Route with path parameters server.addRoute(HttpMethod.get, '/api/users/<userId>', (request) async { final userId = request.url.pathSegments.last; final user = await request.users.getById(userId); return Response.ok(jsonEncode(user)); }); // POST with JSON body server.addRoute(HttpMethod.post, '/api/webhook', (request) async { final body = jsonDecode(await request.readAsString()); await processWebhook(body); return Response.ok('OK'); });
Accessing Services in Routes
Custom route handlers can access all Cubby services through request extensions:
server.addRoute(HttpMethod.get, '/api/dashboard', (request) async { // Authentication info final userId = request.userId; final isAdmin = request.isSuperUser; // Service access final docs = await request.documents.list('posts'); final users = await request.users.list(); final settings = await request.settings(); // Direct database access final db = request.database; return Response.ok(jsonEncode(dashboard)); });
Excluding Routes from Client SDK
Routes meant for internal use only can be excluded from client SDK generation:
server.addRoute( HttpMethod.post, '/internal/sync', handler, ignoreForClient: true, // Won't appear in generated client );
Client SDK Generation
Cubby can generate a fully typed Dart HTTP client from your server routes. The generated client includes all built-in endpoints plus your custom routes, with type-safe method signatures.
Generate the Client
# Default output directory dart run bin/server.dart generate # Custom output path dart run bin/server.dart generate --output=packages/my_client
Initialize the Client
The generated client package must be added as a dependency in your frontend or consuming project. If it was generated into a local packages/ directory, use a path dependency:
dependencies: cubby_client: path: packages/cubby_client
Then initialize the client with your server URL. The client must be created before making any API calls:
import 'package:cubby_client/cubby_client.dart'; final client = CubbyClient(baseUrl: 'http://localhost:8080'); // You must call initialize() before making any requests await client.initialize(); // For Flutter apps, initialize early (e.g. in main) void main() async { final client = CubbyClient( baseUrl: 'https://api.myapp.com', ); await client.initialize(); runApp(MyApp(client: client)); }
Using the Client
final auth = await client.auth.signInWithEmailAndPassword( email: 'user@example.com', password: 'password', ); // Use the access token for authenticated requests final result = await client.documents.list( collectionName: 'posts', limit: 20 ); print(result.count); // Total matching documents print(result.documents); // List<Document>
Filtering
Use the Filter class to build type-safe query filters. Combine multiple conditions with Filter.and() or Filter.or():
// Simple filter final filter = Filter.where('status', isEqualTo: 'published'); // Combine with AND final filter = Filter.and([ Filter.where('status', isEqualTo: 'published'), Filter.where('views', isGreaterThan: 100), ]); // Combine with OR final filter = Filter.or([ Filter.where('role', isEqualTo: 'admin'), Filter.where('role', isEqualTo: 'editor'), ]); final result = await client.documents.list( collectionName: 'posts', filter: filter.build(), );
Available filter operators:
| Operator | Description |
|---|---|
| isEqualTo | Exact match (=) |
| isNotEqualTo | Not equal (!=) |
| isGreaterThan | Greater than (>) |
| isLessThan | Less than (<) |
| isGreaterThanOrEqualTo | Greater or equal (>=) |
| isLessThanOrEqualTo | Less or equal (<=) |
| like | SQL LIKE pattern matching |
| notLike | SQL NOT LIKE pattern matching |
Ordering
Use the OrderBy class to sort results. Chain addField() to sort by multiple columns:
// Sort by newest first final order = OrderBy.desc('created_at'); // Sort by multiple fields final order = OrderBy.desc('created_at') .addField('title', direction: SortDirection.asc); // Combine filter + order + pagination final result = await client.documents.list( collectionName: 'posts', filter: Filter.where('published', isEqualTo: true).build(), orderBy: OrderBy.desc('created_at').build(), limit: 10, offset: 0, );
Route Grouping
All generated client methods are grouped by their path prefix:
// /v1/auth/* → client.auth.* // /v1/documents/* → client.documents.* // /v1/files/* → client.files.* // /api/custom/* → client.custom.*
Realtime
Cubby supports real-time data synchronization via Server-Sent Events (SSE). Subscribe to document changes, file events, or custom channels and receive updates instantly.
Subscribing to Events
Connect to the realtime endpoint with channels you want to listen to:
GET /v1/realtime?sessionId=abc&channels=documents:posts:*,documents:comments:*
Channel Patterns
| Pattern | Receives |
|---|---|
| documents:<collection>:* | All changes in a collection |
| documents:<collection>:<id> | Changes to a specific document |
| files:<bucket>:* | All file changes in a bucket |
Event Types
Three document event types are emitted automatically when documents change:
// Document created { "event": "document_created", "channels": ["documents:posts:*", "documents:posts:abc123"], "document": { "id": "abc123", "title": "Hello", ... } } // Document updated (includes previous state) { "event": "document_updated", "newDocument": { ... }, "oldDocument": { ... } } // Document deleted { "event": "document_deleted", "document": { ... } }
Subscribing with the Client SDK
Use the Channel helper classes to build type-safe channel subscriptions. The subscribe method returns a stream and an unsubscribe callback:
// Subscribe to all changes in a collection final (stream, unsubscribe) = await client.realtime.subscribe( channel: Channel.collection('posts'), ); // Listen for events stream.listen((event) { print(event); // DocumentCreatedEvent, DocumentUpdatedEvent, etc. }); // Unsubscribe when done unsubscribe();
Three channel types are available:
// All changes in a collection Channel.collection('posts') // Only specific event types Channel.collection('posts', type: RealtimeEventType.create) Channel.collection('posts', type: RealtimeEventType.update) Channel.collection('posts', type: RealtimeEventType.delete) // A specific document Channel.document('posts', 'doc_id_123') // Custom channel Channel.custom('notifications:user_123')
Emitting Custom Events
Push custom events to connected clients from your server code:
server.realtime.emit( Transport( event: CustomRealtimeEvent( channels: ['notifications:user_123'], data: {'type': 'message', 'text': 'Hello!'}, ), ), );
Permission-Aware Subscriptions
Realtime subscriptions respect your collection permission rules. Wildcard subscriptions (e.g. documents:posts:*) filter events through the collection's listRule. Single-document subscriptions use the viewRule. Super users receive all events regardless of rules.
Authentication
Cubby provides JWT-based authentication with email/password sign-in, token refresh, and support for OAuth providers.
Email & Password
// Sign in final auth = await server.auth.signInWithEmailAndPassword( email: 'user@example.com', password: 'password', ); auth.accessToken // JWT for API requests auth.refreshToken // Opaque token for refreshing auth.user // User object // Refresh token final newAuth = await server.auth.refreshToken(auth.refreshToken);
OAuth Providers
Cubby supports these OAuth providers, configurable through the dashboard or settings API:
Configure providers via the settings API or dashboard. Each provider requires a client ID and secret:
await server.settings.update( oauthProviders: OAuthProviderList( google: OAuthProvider( clientId: '123.apps.googleusercontent.com', clientSecret: 'GOCSPX-...', ), github: OAuthProvider( clientId: 'your-github-client-id', clientSecret: 'your-github-client-secret', ), ), );
OAuth Authorization Code Flow
The standard web-based OAuth flow works with all providers. Cubby handles the full flow:
// 1. Get the authorization URL POST /v1/auth/oauth2/google?redirectUrl=https://myapp.com/callback // 2. Redirect user to the returned URL // 3. User authorizes, provider calls back to Cubby // 4. Cubby redirects to your redirectUrl with tokens: // https://myapp.com/callback#access_token=...&refresh_token=...&user={...}
Tokens are passed in the URL fragment (#) so they are never sent to your server in access logs. Configure allowed redirect URLs in your settings, including custom URL schemes for mobile apps (e.g. myapp://).
Native Sign-In (Google, Apple, Facebook)
For Flutter and native apps, skip the browser redirect flow entirely. Use the native SDK to get an ID token, then pass it directly to Cubby. Cubby verifies the token signature against the provider's public keys (JWKS), validates the audience matches your configured client ID, and checks email verification.
1. Create Google Cloud credentials
Go to the Google Cloud Console and create a new project (or select an existing one). Then set up the OAuth consent screen:
- Navigate to APIs & Services > OAuth consent screen
- Choose "External" user type and fill in your app name and support email
- Under Data Access (Scopes), ensure these are added: openid, .../auth/userinfo.email, .../auth/userinfo.profile
- Complete the branding section with your app logo and links
2. Create OAuth client IDs
You need separate credentials for each platform. Go to APIs & Services > Credentials > Create Credentials > OAuth Client ID:
- Web application — Add your Cubby server URL to Authorized JavaScript Origins and the callback URL to Authorized Redirect URIs (e.g. https://your-server.com/v1/auth/oauth2/google/callback)
- iOS — Enter your app's Bundle ID (e.g. com.example.myapp)
- Android — Enter your package name and the SHA-1 fingerprint from your signing key (use keytool -list -v -keystore ~/.android/debug.keystore for debug)
3. Configure Cubby
Pass all client IDs as a comma-separated string with the web client ID first. The web client secret is used for the authorization code flow:
google: OAuthProvider( clientId: [ 'WEB_CLIENT_ID.apps.googleusercontent.com', 'IOS_CLIENT_ID.apps.googleusercontent.com', 'ANDROID_CLIENT_ID.apps.googleusercontent.com', ].join(','), clientSecret: 'GOCSPX-your-web-client-secret', )
Why multiple client IDs? The web client ID is used for the browser-based authorization code flow. Native sign-in validates the ID token audience (aud) against all configured client IDs, so each platform needs its own.
4. Flutter integration
Add the google_sign_in package and configure platform-specific settings:
dependencies: google_sign_in: ^6.0.0
On iOS, add your iOS client ID as GIDClientID in ios/Runner/Info.plist and add a URL scheme matching the reversed client ID. On Android, no additional config is needed if your SHA-1 fingerprint is registered.
import 'package:google_sign_in/google_sign_in.dart'; final googleSignIn = GoogleSignIn( serverClientId: 'WEB_CLIENT_ID.apps.googleusercontent.com', ); final googleUser = await googleSignIn.signIn(); final googleAuth = await googleUser?.authentication; final idToken = googleAuth?.idToken; final auth = await client.auth.signInWithIdToken( provider: IdTokenAuthProvider.google, idToken: idToken!, );
User Management
Create and manage users via CLI or programmatic API:
# Create a user cubby users create -e user@example.com -p password123 -n "John" # Create an admin cubby users create -e admin@example.com -p password123 -s # List users cubby users list --json # Generate a one-time password cubby users generate-otp <userId>
Cubby integrates the mailer package for sending emails. Configure your SMTP server through the dashboard or settings API, then send emails from hooks, custom routes, or anywhere in your server code.
Configure SMTP
Set up your mail provider through the settings API:
await server.settings.update( mail: MailSettings( smtpServer: 'smtp.gmail.com', smtpPort: 587, username: 'you@gmail.com', password: 'your-app-password', useSsl: false, fromAddress: 'noreply@myapp.com', fromName: 'My App', ), );
Send Emails
Use the smtpServer() helper to get a configured SmtpServer instance, then send messages using the mailer package directly:
import 'package:mailer/mailer.dart'; import 'package:mailer/smtp_server.dart'; final smtp = await server.smtpServer(); final settings = await server.settings.get(); final message = Message() ..from = Address( settings.mail!.fromAddress, settings.mail!.fromName, ) ..recipients.add('user@example.com') ..subject = 'Welcome to My App' ..html = '<h1>Welcome!</h1><p>Thanks for signing up.</p>'; final report = await send(message, smtp);
Send from Custom Routes
In custom route handlers, access the SMTP server through the request object:
server.addRoute(HttpMethod.post, '/api/invite', (request) async { final smtp = await request.smtpServer(); final settings = await request.settings(); final message = Message() ..from = Address(settings.mail!.fromAddress, settings.mail!.fromName) ..recipients.add(recipientEmail) ..subject = 'You\'re invited!' ..html = inviteHtml; await send(message, smtp); return Response.ok('Invite sent'); });
Built-in Email Features
Cubby includes built-in email flows for authentication. These work automatically once SMTP is configured:
| Feature | Endpoint | Description |
|---|---|---|
| OTP Sign-In | POST /v1/auth/send-otp | Sends a 6-character one-time password via email (expires in 10 minutes) |
| Password Reset | POST /v1/auth/forgot-password | Sends a password reset link with a secure token (expires in 60 minutes) |
Custom Email Templates
Override the default OTP and password reset email templates with your own HTML. Templates use Mustache syntax for variable interpolation:
await server.settings.update( mail: MailSettings( smtpServer: 'smtp.example.com', smtpPort: 587, fromAddress: 'noreply@myapp.com', fromName: 'My App', otpTemplate: '<h1>Your code: {{otp_code}}</h1>', resetPasswordTemplate: '<a href="{{reset_url}}">Reset password</a>', ), );
Available template variables:
| Template | Variables |
|---|---|
| OTP | {{otp_code}}, {{user.email}}, {{user.name}}, {{user.id}} |
| Password Reset | {{reset_url}}, {{user.email}}, {{user.name}}, {{user.id}} |