Mast Sync Protocol v2.0 Specification#
Overview#
This is a clean v2 specification that starts fresh from v1, incorporating lessons learned to solve critical issues while maintaining simplicity. This replaces the previous over-engineered v2 implementation with a focused, production-ready protocol.
Core Design Principles#
- Secure by Default: All operations require authentication, rooms are private by default
- Simple & Clean: Remove unnecessary complexity while maintaining reliability
- Payment Ready: Built-in support for paid room creation with backwards compatibility
- CRDT-Native: Leverage CR-SQLite's conflict resolution, no complex coordination needed
- Immediate Feedback: Clients know their room status immediately after connection
Authentication & Authorization#
Key Management#
- ECDSA P-256 public key cryptography
- Automatic registration on first connection (when payment enforcement disabled)
- Room-scoped permissions: read, write, invite capabilities
- Signature format:
{type}:{data-json}
Environment-Based Enforcement#
# Development/Testing (default)
REQUIRE_AUTH=false # Auto-grant invite permissions to any connecting user
# Production (future)
REQUIRE_AUTH=true # Only users in auth table can access rooms
Connection Lifecycle#
1. WebSocket Connection#
URL: wss://server/sync?room={roomId}&publicKey={base64PublicKey}
2. Immediate Room Status#
Upon connection, server immediately sends room status:
{
"type": "room_status",
"access": "write|read|none|no_room"
}
Access Levels:
"write": Full read/write access to existing room"read": Read-only access to existing room"none": Room exists but no permissions granted (need to be invited -- later)"no_room": Room doesn't exist (payment may be required if they wish to create one)
3. Room Access Behavior#
When REQUIRE_AUTH=false (Development):
- Any room → Auto-grant invite permissions (read + write)
- New rooms created automatically
- User receives
"access": "write"
When REQUIRE_AUTH=true (Production):
- Only authenticated users can access
- User must exist in room_keys table
- User receives access level based on permissions
Core Sync Protocol#
Missing Changes Problem Solution#
The critical flaw in v1 was using MAX(db_version) which caused missing intermediate versions.
Problem: Client has versions [1,2,5,10] and requests > 10, never getting versions 3,4,6,7,8,9.
Solution: Track highest contiguous version + explicit missing ranges.
Version State Tracking#
const versionState = {
contiguousUpTo: 2, // Highest version with no gaps before it
missingRanges: [ // Explicit gaps we know about
{start: 3, end: 4}, // Missing versions 3-4
{start: 6, end: 9} // Missing versions 6-9
],
maxVersionSeen: 10 // Highest version ever seen
};
Change Synchronization#
Sync Request (Client → Server)#
{
"type": "sync_request",
"site_id": "base64-site-id",
"contiguous_up_to": 2,
"missing_ranges": [
{"start": 3, "end": 4},
{"start": 6, "end": 9}
],
"max_version_seen": 10,
"publicKey": "base64-public-key"
}
Sync Response (Server → Client)#
{
"type": "sync_response",
"current_max_version": 15,
"changes": [
// Missing ranges 3-4, 6-9
// Plus new changes 11-15
{
"TableName": "todos",
"PK": "base64-pk",
"ColumnName": "description",
"Value": "Task content",
"ColVersion": 15,
"DBVersion": 8,
"SiteID": "base64-site-id",
"CL": 1,
"Seq": 1
}
]
}
Change Push (Write Operations)#
{
"type": "changes",
"publicKey": "base64-public-key",
"signature": "base64-signature",
"data": [...changes...]
}
Signature payload: changes:{JSON.stringify(data)}
Real-time Broadcasting#
Server broadcasts changes to all authorized clients in room (excluding sender):
{
"type": "changes",
"data": [...changes...]
}
Server Architecture#
Essential Components#
- ✅ ECDSA signature verification
- ✅ Room-based isolation
- ✅ Permission checking
- ✅ Auto room creation (with environment flag)
- ✅ Real-time broadcasting
- ✅ Connection cleanup on auth failure
Database Schema#
CREATE TABLE rooms (
room_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE room_keys (
room_id TEXT NOT NULL,
public_key TEXT NOT NULL,
can_read BOOLEAN NOT NULL DEFAULT 1,
can_write BOOLEAN NOT NULL DEFAULT 1,
can_invite BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
PRIMARY KEY (room_id, public_key)
);
Server Version Query Logic#
Multi-Range SQL Query#
-- Get missing ranges + new changes
SELECT * FROM crsql_changes
WHERE site_id != ?
AND (
-- Missing range 3-4
(db_version >= 3 AND db_version <= 4) OR
-- Missing range 6-9
(db_version >= 6 AND db_version <= 9) OR
-- New changes 11-15
(db_version > 10)
)
ORDER BY db_version ASC
Client Version State Updates#
function updateVersionState(changes) {
for (const change of changes) {
const version = change.DBVersion;
// Update max seen
versionState.maxVersionSeen = Math.max(versionState.maxVersionSeen, version);
// Fill gaps and update contiguous
fillGapsAndUpdateContiguous(version);
}
}
function fillGapsAndUpdateContiguous(version) {
// Remove version from missing ranges
removeMissingVersion(version);
// Extend contiguous if possible
while (versionState.contiguousUpTo + 1 <= versionState.maxVersionSeen &&
!isVersionMissing(versionState.contiguousUpTo + 1)) {
versionState.contiguousUpTo++;
}
}
Auto-Registration Flow#
Connection Logic#
- Client connects with
publicKeyparameter - Server checks room + key permissions
- When
REQUIRE_AUTH=false:- Auto-grant invite permissions to any user
- Create room if it doesn't exist
- When
REQUIRE_AUTH=true:- Only users in auth table can access
- No auto-registration
Permission Granting Logic#
// Auto-grant invite permissions (read + write)
func AutoGrantInvitePermissions(roomID, publicKey string) error {
return GrantPermissions(roomID, publicKey, true, true, true) // read, write, invite
}
Client Implementation#
Connection Management#
// Simple reconnection (no exponential backoff)
function attemptReconnection(room: string) {
setTimeout(() => {
connectWebSocket(room).catch(() => attemptReconnection(room));
}, math.Rand(3, 9); // Between 3 and 9 seconds retry, to stop stampeding herd
}
Authentication Integration#
// Include publicKey in all requests
async function sendSyncRequest(connection) {
const syncRequest = {
type: "sync_request",
site_id: connection.siteId,
last_version: connection.lastSyncVersion,
publicKey // Always include for all syncRequest
};
ws.send(JSON.stringify(syncRequest));
}
Room Status Handling#
// Handle immediate room status
case 'room_status':
self.postMessage({
type: 'room_status',
dbname: dbname,
access: msg.access,
needsPayment: msg.access === 'no_room'
});
break;
Server Implementation#
Core Logic#
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("room")
publicKey := r.URL.Query().Get("publicKey")
// Check authentication/auto-grant permissions
access := determineAccess(roomID, publicKey)
// Upgrade WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Send immediate room status
sendRoomStatus(conn, access)
// Handle sync protocol
handleSyncProtocol(conn, roomID, publicKey)
}
func determineAccess(roomID, publicKey string) string {
if !requireAuth {
// Development mode - auto-grant invite permissions
autoGrantInvitePermissions(roomID, publicKey)
return "write"
}
// Production mode - check existing permissions
return checkUserPermissions(roomID, publicKey)
}
Error Handling#
Structured Error Responses#
{
"type": "error",
"code": "AUTH_FAILED|ROOM_NOT_FOUND|PAYMENT_REQUIRED|PERMISSION_DENIED",
"message": "Human readable description"
}
Authentication Failure Behavior#
- Invalid signature: Close connection immediately
- No read permission: Close connection immediately
- No write permission: Reject change, keep connection open
- No room: Send
room_statuswith"access": "no_room"
Migration from v1#
Environment Variable Control#
# Start with development mode
REQUIRE_AUTH=false
# Switch to production when ready
REQUIRE_AUTH=true
Protocol Changes from v1#
- Replace
"pull"message with"sync_request" - Add version range tracking instead of simple
last_version - Add immediate
"room_status"response - Add
publicKeyto WebSocket URL and all requests
Seamless Transition#
- v1 rooms continue working unchanged
- Auto-registration ensures no user disruption
- Environment variable provides clean cutoff point
Security Model#
Transport Security#
- WSS required for production (TLS 1.3 minimum)
- Certificate validation on client
Message Security#
- ECDSA P-256 signatures for all write operations
- Public key authentication for all read operations
- Room isolation - no cross-room access
Key Security#
- Web Crypto API for key generation
- Secure storage (recommend upgrade from localStorage for production)
- No key transmission (only public keys sent to server)
CR-SQLite Benefits#
- Offline-first: Changes work immediately without server
- Conflict-free: Automatic merge resolution
- Consistent: Guaranteed eventual consistency
- Efficient: Delta-only synchronization
Implementation Priorities#
Phase 1: Core Protocol#
- Implement missing changes solution with version range tracking through sync_request messages
- Version state tracking on client side
- Version requests in sync_request messages
- Response from server giving exactly the changes requested
- Add immediate room_status messages
- Simple reconnection (3-second retry)
- Room status handling in UI (SyncStatus component)
Phase 2: Authorization#
- Environment-based auth enforcement (
REQUIRE_AUTHflag) - Auto-registration for development mode
- ECDSA signature verification (real implementation)
- publicKey authentication for all operations
- Connection cleanup on auth failure
Configuration#
Server Configuration#
// Environment variables
var (
REQUIRE_AUTH = os.Getenv("REQUIRE_AUTH") == "true"
)
Client Configuration#
interface SyncConfig {
room: string;
endpoint: string;
autoReconnect?: boolean; // Default: true
reconnectDelay?: number; // Default: 3000ms
}