gbaas_sdk 3.1.0
gbaas_sdk: ^3.1.0 copied to clipboard
Dart SDK for BaaS (Backend as a Service) - auth, database, storage, realtime, functions, admin APIs.
gbaas_sdk #
Official Dart SDK for BaaS (Backend as a Service). Pure Dart — works in Flutter, server-side Dart, and Dart CLI.
What's new (unreleased) #
- Refresh tokens:
loginnow returns a rotating refresh token; on a 401 the client silently refreshes once (deduped across concurrent requests) and retries. OptionalRefreshTokenStorageinterface persists it across app restarts;auth.refresh()for manual refresh. - Realtime server-side filters:
subscribe(..., filter: {'status': 'eq.pending'})narrows delivery on the server (PostgREST-styleop.value). - Realtime Broadcast & Presence: ephemeral channels —
subscribeBroadcast/broadcast/unsubscribeBroadcastandsubscribePresence/leavePresence. No DB, no RLS. - Storage: image transforms on the download route (
?width=&height=&quality=&format=) and per-bucket upload size caps (max_bytes).
What's new in 2.0.0 #
- Security fix:
validateResetTokenno longer always returnstrue. Upgrade if you ship a password-reset UI. BaasClient.onAuthStateChangebroadcast stream (signedIn/signedOut/sessionExpired).- Pluggable
SessionStorage— drop influtter_secure_storage(or anything) to persist the JWT. - 401 auto-handling: any authenticated request that comes back 401 clears the session, emits
sessionExpired, and throwsUnauthorizedException. - Query builder:
isNull/isNotNulland the renamedinFilter(wasinList) now emit correct PostgREST syntax. - Storage:
uploadactually respectsbucket(sent asbucket_id) andpath;getFilereturnsStorageFileWithUrl. - Realtime: parses concatenated JSON frames so coalesced events are no longer dropped; events are delivered per-row under RLS to any authenticated user (not admin-only).
See CHANGELOG.md for the full breaking-changes list and KNOWN_ISSUES.md for what remains.
Installation #
dart pub add gbaas_sdk
Migration from 1.x #
// 1.x
await client.from('users').inList('status', ['active', 'pending']).get();
// 2.0
await client.from('users').inFilter('status', ['active', 'pending']).get();
// 1.x — getFile returned a (broken, empty) StorageFile
final file = await client.storage.getFile('id');
print(file.name); // ''
// 2.0 — getFile returns metadata + signed URL
final result = await client.storage.getFile('id');
print(result.file.name); // 'photo.jpg'
print(result.url); // 'https://.../photo.jpg?X-Amz-...'
// 1.x — updateProfile (removed; was a 404)
await client.auth.updateProfile(email: '...');
// 2.0 — use admin users API or your own profile table.
// 1.x — restoreSession required the token
await client.auth.restoreSession(token);
// 2.0 — reads from SessionStorage by default
await client.auth.restoreSession();
Session persistence (Flutter) #
The SDK keeps tokens in memory by default. Implement SessionStorage
once with your favorite secure store and pass it to BaasClient. To also
survive app restarts with silent token refresh, implement the optional
RefreshTokenStorage interface on the same adapter — it is a separate
interface, so existing SessionStorage adapters keep compiling; without it
auto-refresh still works in-session, but a restart loses the refresh token.
The default InMemorySessionStorage already implements both.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:gbaas_sdk/gbaas_sdk.dart';
class SecureSessionStorage implements SessionStorage, RefreshTokenStorage {
final _store = const FlutterSecureStorage();
@override Future<String?> readToken() => _store.read(key: 'baas_token');
@override Future<void> writeToken(String t) => _store.write(key: 'baas_token', value: t);
@override Future<void> deleteToken() => _store.delete(key: 'baas_token');
@override Future<String?> readRefreshToken() => _store.read(key: 'baas_refresh');
@override Future<void> writeRefreshToken(String t) => _store.write(key: 'baas_refresh', value: t);
@override Future<void> deleteRefreshToken() => _store.delete(key: 'baas_refresh');
}
final baas = BaasClient(
baseUrl: '...',
apiKey: '...',
sessionStorage: SecureSessionStorage(),
);
// At boot: pick up the persisted tokens, validate against /api/profile.
// With a persisted refresh token, an expired access token refreshes
// silently instead of forcing a re-login.
await baas.auth.restoreSession();
// React to lifecycle events.
baas.onAuthStateChange.listen((s) {
if (s == AuthState.sessionExpired) {
// Redirect to login screen.
}
});
Known limitations #
- Realtime — per-row RLS: the backend delivers events to any authenticated subscriber, filtered per-row by the same RLS policies as REST/RPC (fail-closed). Subscribe to a table to receive its row events. See
KNOWN_ISSUES.md. - MFA endpoints: shape mismatches around setup/enable/finalize against the current backend; methods stay on the surface but expect to revisit them. See
KNOWN_ISSUES.md. - Refresh tokens — email/password only: only
login(and/api/auth/refresh) issues the rotating refresh pair. MFA finalize, OAuth, passwordless OTP/magic-link, and anonymous sign-in mint access-only tokens — those sessions cannot silently refresh and expire with the access token. - Broadcast/Presence — single instance: channels are in-memory per backend instance; messages do not fan out across a multi-instance deployment.
Quick Start #
import 'package:gbaas_sdk/gbaas_sdk.dart';
final baas = BaasClient(
baseUrl: 'https://your-baas-instance.com',
apiKey: 'baas_sk_...',
);
// Auth
await baas.auth.login(email: 'user@example.com', password: 'password');
// Database
final users = await baas.from('users').select().get();
// Storage
await baas.storage.upload(bucket: 'avatars', path: 'user.png', bytes: bytes, filename: 'avatar.png');
// Realtime
baas.realtime.subscribe('posts', (event) => print(event));
// Functions
final result = await baas.functions.invoke('my-function', body: {'key': 'value'});
Modules #
Authentication #
// Register
final session = await baas.auth.register(
email: 'new@example.com',
password: 'password123',
);
// Login
final session = await baas.auth.login(
email: 'user@example.com',
password: 'password',
);
// MFA
final mfaSetup = await baas.auth.setupMfa();
await baas.auth.enableMfa(code: '123456');
// OAuth
final providers = await baas.auth.getProviders();
final url = baas.auth.getOAuthUrl('google');
// Password reset
await baas.auth.forgotPassword(email: 'user@example.com');
await baas.auth.resetPassword(token: 'reset-token', newPassword: 'newpass');
// Email verification
await baas.auth.requestEmailVerification();
await baas.auth.verifyEmail(token: 'token');
// Profile
final user = await baas.auth.getProfile();
await baas.auth.updateProfile(email: 'new@email.com');
// Session & token refresh
await baas.auth.restoreSession(); // reads SessionStorage (+ refresh token if persisted)
await baas.auth.refresh(); // manual refresh — automatic on 401, see below
await baas.auth.logout(); // revokes refresh tokens server-side, clears both locally
baas.http.setTokens(accessToken: '...', refreshToken: '...'); // inject external tokens
login stores the rotating refresh token next to the access token. When an
authenticated request comes back 401, the client calls /api/auth/refresh once
(concurrent 401s are deduped into a single refresh) and retries the request;
sessionExpired fires only when the refresh itself fails or no refresh token is
available. Only email/password login issues refresh tokens — see
Known limitations.
Database #
// Query with filters
final posts = await baas.from('posts')
.select()
.eq('status', 'published')
.gte('created_at', '2024-01-01')
.orderDesc('created_at')
.limit(10)
.get();
// Get single record
final post = await baas.from('posts').getById('post-id');
// Insert
final newPost = await baas.from('posts').insert({
'title': 'New Post',
'content': 'Content here',
});
// Update
await baas.from('posts').update('post-id', {
'title': 'Updated Title',
});
// Delete
await baas.from('posts').delete('post-id');
// Raw SQL (admin only)
final results = await baas.database.executeRawSql('SELECT * FROM users LIMIT 10');
Filter Operators
.eq('column', value) // =
.neq('column', value) // !=
.gt('column', value) // >
.gte('column', value) // >=
.lt('column', value) // <
.lte('column', value) // <=
.like('column', '%pattern%')
.ilike('column', '%pattern%') // case insensitive
.isNull('column')
.isNotNull('column')
.inList('column', [1, 2, 3])
Storage #
// Create bucket
final bucket = await baas.storage.createBucket(name: 'avatars', isPublic: true);
// Upload file
final result = await baas.storage.upload(
bucket: 'avatars',
path: 'user123/avatar.png',
bytes: fileBytes,
filename: 'avatar.png',
);
// List files
final files = await baas.storage.listFiles(bucketId: 'avatars');
// Get download URL
final url = await baas.storage.getPresignedUrl(fileId: 'file-id', expiresIn: 3600);
// Public download URL (backend route — supports image transforms)
final publicUrl = baas.storage.getPublicUrl(fileId: 'file-id');
final thumbUrl = '$publicUrl?width=200&height=200&format=jpeg&quality=80';
// Download bytes (streams via the backend, enforces public/RLS rules)
final bytes = await baas.storage.download('file-id');
// Delete file
await baas.storage.deleteFile('file-id');
Image transforms run on GET /api/storage/files/{id}/download via query params
width, height, quality (JPEG, 1-100), format (jpeg|png) — after the
public/RLS check. Source formats: jpeg/png/gif/tiff/bmp (gif re-encodes to a
static png; webp/avif pass through untouched). Output dimensions are clamped to
4096; sources over 32 MiB or 25 megapixels are served untransformed, and any
transform error falls back to serving the original file.
Uploads are size-capped: globally by the server's STORAGE_MAX_UPLOAD_BYTES
(default 10 MiB) plus an optional per-bucket max_bytes (null = no bucket
cap; not yet exposed on createBucket — set it through the bucket API or
dashboard).
Realtime #
// Subscribe to all changes
final sub = baas.realtime.subscribe('posts', (event) {
print('Event: ${event.type} on ${event.table}');
print('Data: ${event.newData}');
});
// Subscribe to specific events
baas.realtime.onInsert('posts', (event) => print('New post!'));
baas.realtime.onUpdate('posts', (event) => print('Post updated!'));
baas.realtime.onDelete('posts', (event) => print('Post deleted!'));
// Server-side value filters (PostgREST "op.value"; a bare value means eq)
// Operators: eq, neq, gt, gte, lt, lte, in (comma list), like (% and _)
baas.realtime.subscribe('orders', (event) => print(event),
filter: {'status': 'eq.pending', 'total': 'gt.100'});
// Broadcast — ephemeral pub/sub (no DB, no RLS): UI sync, typing, chat
baas.realtime.subscribeBroadcast('room-42', (payload) => print(payload));
baas.realtime.broadcast('room-42', {'typing': true});
baas.realtime.unsubscribeBroadcast('room-42');
// Presence — who is online, with arbitrary per-member state
baas.realtime.subscribePresence('lobby', {'status': 'online'}, (msg) {
// msg: {type: 'presence', channel: 'lobby', action: 'sync'|'join'|'leave',
// members: [{user_id, role, state}]}
});
baas.realtime.leavePresence('lobby');
// Connection state
baas.realtime.onConnectionStateChange = (state) {
print('Connection: $state');
};
// Unsubscribe
sub.unsubscribe();
// Disconnect
baas.realtime.disconnect();
Filters narrow delivery only — RLS still authorizes every row. The filter is
per-subscription: it is pushed to the server only while every subscriber on the
table shares the identical filter; otherwise the table streams unfiltered
server-side and each subscriber's filter is applied client-side (same matcher).
Presence joiners receive the full roster (sync); peers get join/leave, and
a disconnect auto-leaves. Broadcast/presence channels re-join automatically on
reconnect, including for clients with no table subscriptions.
Edge Functions #
// Execute by ID
final result = await baas.functions.invoke('function-id', body: {'key': 'value'});
print(result.isSuccess ? result.data : result.error);
// Execute by name
final result = await baas.functions.invokeByName('my-function', body: {'key': 'value'});
// Execute code directly
final result = await baas.functions.executeCode(code: 'return { hello: "world" };');
// CRUD
final fns = await baas.functions.list();
await baas.functions.create(name: 'my-fn', code: 'return {};', language: 'javascript');
await baas.functions.update('id', name: 'new-name');
await baas.functions.delete('id');
// Hooks (attach functions to events)
await baas.functions.createHook(eventType: 'INSERT', functionId: 'fn-id', tableName: 'posts');
final hooks = await baas.functions.listHooks();
await baas.functions.deleteHook('hook-id');
GraphQL #
final result = await baas.graphql.query(
query: '{ users { id email } }',
variables: {'limit': 10},
);
final schema = await baas.graphql.getSchema();
final playgroundUrl = baas.graphql.getPlaygroundUrl();
Admin Modules #
Webhooks #
final webhooks = await baas.webhooks.list();
final wh = await baas.webhooks.create(url: 'https://example.com/hook', events: ['INSERT']);
await baas.webhooks.update('id', url: 'https://new-url.com');
await baas.webhooks.test('id');
await baas.webhooks.delete('id');
Policies (RLS) #
final policies = await baas.policies.list();
await baas.policies.create(name: 'read_own', table: 'posts', operation: 'SELECT', expression: 'user_id = auth.uid()');
await baas.policies.delete('id');
API Keys #
final keys = await baas.apiKeys.list();
await baas.apiKeys.create(name: 'mobile-app');
await baas.apiKeys.revoke('id');
await baas.apiKeys.delete('id');
// Instance token
final token = await baas.apiKeys.getInstanceToken();
await baas.apiKeys.regenerateInstanceToken();
Backups #
final backups = await baas.backups.list();
await baas.backups.create(description: 'Before migration');
final backup = await baas.backups.get('id');
await baas.backups.restore('id');
final url = await baas.backups.getDownloadUrl('id');
await baas.backups.delete('id');
// Import
final tables = await baas.backups.listTables();
await baas.backups.importFile(table: 'users', bytes: csvBytes, filename: 'users.csv', format: 'csv');
Migrations #
final migrations = await baas.migrations.list();
await baas.migrations.create(name: 'add_posts_table', sql: 'CREATE TABLE posts (...)');
await baas.migrations.apply('id');
await baas.migrations.rollback('id');
await baas.migrations.delete('id');
// Auto-generate migration from current schema diff
final diff = await baas.migrations.generate();
Email #
// Send email
await baas.email.send(to: 'user@example.com', subject: 'Welcome', body: '<h1>Hello!</h1>');
// Email config
final config = await baas.email.getConfig();
await baas.email.saveConfig(config: {'provider': 'smtp', 'host': 'smtp.gmail.com'});
// Templates
final templates = await baas.email.listTemplates();
await baas.email.createTemplate(name: 'welcome', subject: 'Welcome!', body: '<h1>Hi {{name}}</h1>');
await baas.email.updateTemplate('id', subject: 'Updated subject');
await baas.email.deleteTemplate('id');
// Logs
final logs = await baas.email.getLogs();
Cron Jobs #
final jobs = await baas.jobs.list();
await baas.jobs.create(name: 'cleanup', schedule: '0 0 * * *', functionId: 'fn-id');
await baas.jobs.update('id', schedule: '0 */6 * * *');
await baas.jobs.toggle('id');
await baas.jobs.runNow('id');
final execs = await baas.jobs.getExecutions('id');
await baas.jobs.delete('id');
Environment Variables #
final vars = await baas.envVars.list();
await baas.envVars.create(key: 'STRIPE_KEY', value: 'sk_live_...');
await baas.envVars.update('id', value: 'new-value');
await baas.envVars.delete('id');
Database Branches #
final branches = await baas.branches.list();
await baas.branches.create(name: 'feature-x', sourceBranch: 'main');
await baas.branches.merge('branch-id');
await baas.branches.reset('branch-id');
await baas.branches.delete('branch-id');
Log Drains #
final drains = await baas.logDrains.list();
await baas.logDrains.create(name: 'datadog', type: 'datadog', config: {'api_key': '...'});
await baas.logDrains.update('id', config: {'api_key': 'new-key'});
await baas.logDrains.toggle('id');
await baas.logDrains.test('id');
await baas.logDrains.delete('id');
Audit Logs #
final logs = await baas.audit.list(action: 'LOGIN', limit: 50);
final actions = await baas.audit.getActions();
Metrics #
final stats = await baas.metrics.getDashboardStats();
final requestLogs = await baas.metrics.getRequestLogs(limit: 100);
final appLogs = await baas.metrics.getApplicationLogs(level: 'error');
final timeseries = await baas.metrics.getTimeseries(metric: 'requests', period: '24h');
IP Whitelist #
final ips = await baas.ipWhitelist.list();
await baas.ipWhitelist.create(ip: '192.168.1.0/24', description: 'Office');
await baas.ipWhitelist.delete('id');
CORS Origins #
final origins = await baas.corsOrigins.list();
await baas.corsOrigins.create(origin: 'https://myapp.com');
await baas.corsOrigins.delete('id');
Environments #
final status = await baas.environments.status();
await baas.environments.init(name: 'staging');
await baas.environments.promote(from: 'staging', to: 'production');
await baas.environments.revert(environment: 'production');
Search #
final results = await baas.search.search(query: 'hello', table: 'posts');
await baas.search.createIndex(table: 'posts', columns: ['title', 'content']);
Users (Admin) #
final users = await baas.users.list(page: 1, limit: 50);
await baas.users.update('user-id', role: 'admin');
await baas.users.delete('user-id');
Flutter Integration #
import 'package:flutter/material.dart';
import 'package:gbaas_sdk/gbaas_sdk.dart';
final baas = BaasClient(
baseUrl: 'http://10.0.2.2:8086', // Android emulator localhost
apiKey: 'baas_sk_...',
);
Persist Session #
import 'package:shared_preferences/shared_preferences.dart';
// Save token after login
final session = await baas.auth.login(email: email, password: password);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('access_token', session.accessToken);
// Restore on app start
final token = prefs.getString('access_token');
if (token != null) {
await baas.auth.restoreSession(token);
}
Error Handling #
try {
await baas.auth.login(email: email, password: password);
} on AuthException catch (e) {
if (e.data?['mfa_required'] == true) {
// Handle MFA
final tempToken = e.data!['temp_token'];
await baas.auth.verifyMfa(tempToken: tempToken, code: userCode);
} else {
print('Auth error: ${e.message}');
}
} on ValidationException catch (e) {
print('Validation error: ${e.errors}');
} on BaasException catch (e) {
print('Error: ${e.message} (${e.statusCode})');
}
Cleanup #
baas.dispose(); // Closes WebSocket and HTTP connections
License #
MIT